Merge pull request #964 from telemt/flow/3.5.16

Flow/3.5.16
This commit is contained in:
Alexey
2026-10-31 14:52:11 +03:00
committed by GitHub
74 changed files with 1475 additions and 354 deletions
+8
View File
@@ -210,6 +210,14 @@ Use English for newly written or substantively updated source comments, document
- Follow local formatting and naming patterns. Avoid style-only churn.
- Remove an obsolete item only when doing so is necessary for the task; do not perform general warning or dead-code cleanup by default.
### 12.1 Mermaid Diagram Validation
- When adding or changing Mermaid diagrams, validate every Mermaid block in the affected documents, including localized versions, with a real Mermaid parser (`mermaid.parse`) or Mermaid CLI (`mmdc`). Checking Markdown fences alone is insufficient.
- Fix parse errors before completing the change. For syntax fixes, reproduce the original failure before validating the correction when practical.
- When changing diagram layout, also render and inspect readability, label clipping, and connections when a renderer is available.
- Avoid unescaped semicolons in sequence-diagram labels and notes because Mermaid can interpret them as statement separators. Use commas or supported escaping instead.
- Report the Mermaid version and actual validation results. Distinguish parser validation, rendering, and GitHub verification; mark checks that were not executed as **NOT RUN** or **BLOCKED**, as applicable.
## 13. Communication and Deliverables
For a simple answer, answer directly. For a nontrivial implementation, briefly convey the plan, meaningful impact, and verification. Avoid repetitive process narration.
Generated
+1 -1
View File
@@ -2780,7 +2780,7 @@ dependencies = [
[[package]]
name = "telemt"
version = "3.5.15"
version = "3.5.16"
dependencies = [
"aes",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.5.15"
version = "3.5.16"
edition = "2024"
rust-version = "1.88"
+15 -1
View File
@@ -311,6 +311,20 @@ Example — replace `[[server.listeners]]` (other `[server]` fields including `[
{"server": {"listeners": [{"ip": "0.0.0.0", "port": 443, "client_mss": "92"}]}}
```
#### WEB retry jitter configuration
The managed `web.retry_jitter` object supports `enabled`, `min_ms`, `max_ms`,
`distribution` (`uniform` or `triangular`) and optional triangular `peak_ms`.
Validation is identical to file configuration, including when disabled. Reload
updates newly rendered bridge pages without a process restart. Existing pages
retain their policy through recovery; the recovery representation is unchanged.
Require `0 <= min_ms <= max_ms <= 30000`; an explicit `peak_ms` is valid only
with `triangular` and must lie within that interval. Unknown keys and
distributions are rejected. The range is an additive delay, not the total pause.
See [retry jitter parameters](../../Config_params/CONFIG_PARAMS.en.md#webretry_jitter)
and the [WEB guide](../../WEB/WEB_PROXY.en.md#configurable-http-retry-jitter)
for defaults, budgets and compatibility semantics.
### `RotateSecretRequest`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
@@ -417,7 +431,7 @@ for `POST /v1/runtime/web/lifecycle/pause` and `/resume`, and:
for `POST /v1/runtime/web/lifecycle/drain`, where `timeout_secs` is bounded to `1..=3600`. Pause and resume return `200`; drain freezes one monotonic absolute deadline and returns `202` without waiting for completion. A second drain while one is `draining` or `force_closing` returns `409 web_lifecycle_in_progress` and cannot alter the first deadline. Repeated pause/resume requests already satisfied by the current state are idempotent and do not advance the lifecycle epoch. Pause during an active drain leaves that drain running. Resume cancels an active drain and opens admission; if the deadline already committed its forced-close snapshot, those old session close signals remain effective.
Pause and drain block bootstrap issuance, initial/replacement session creation, and logical-stream admission. Exact session-creation replay, existing DATA/WINDOW/CLOSE, carrier polling/WebSocket exchanges, and explicit session DELETE remain available. Rejection does not consume bootstrap/session/stream rate or quota state: authenticated session creation returns retryable `503` with `Retry-After: 1`, while bridge issuance preserves the decoy route and a rejected logical `OPEN` receives a stream-local close.
Pause and drain block bootstrap issuance, initial/replacement session creation, and logical-stream admission. Exact session-creation replay, existing DATA/WINDOW/CLOSE, carrier polling/WebSocket exchanges, and explicit session DELETE remain available. Rejection does not consume bootstrap/session/stream rate or quota state: authenticated session creation returns retryable `503` with `Retry-After: 1` and `X-Telemt-Retry-After-Ms: 250`, while bridge issuance preserves the decoy route and a rejected logical `OPEN` receives a stream-local close.
Drain remains graceful until either all live sessions, logical-stream ownership, and session-owned WebSockets reach zero or its deadline fires. The deadline is the latest time to commit close signals, not a claim that cooperative task teardown is already complete. At the deadline every remaining live session receives an idempotent close signal outside manager locks, status becomes `force_closing`, and only confirmed zero publishes `drained` with outcome `forced`. Natural zero publishes outcome `graceful`. Both outcomes keep operator admission closed until explicit resume.
+34 -2
View File
@@ -2616,11 +2616,11 @@ Der WEB-Modus transportiert MTProxy-Datenverkehr von Telegram Desktop über HTTP
| `timeouts` | Tabelle | begrenzte Defaults | `✔` |
| `vhosts` | Tabellen-Array | `[]` | `✔` |
`enabled = true` erfordert mindestens einen durch die Netzwerkrichtlinie zugelassenen WEB-Listener, einen vhost und mindestens ein Profil in jedem vhost. `https` behält den serialisierten HTTPS-Transport bei und erfordert `max_http_handlers >= 2`. Mit `https-lanes` erhalten Stream null und jeder logische Stream eigene Uplink-Sequenzen, Downlink-Cursor, Wiederholungen und Long Polls; dieser Carrier erfordert `max_http_handlers >= 4` und öffentliches HTTP/2 am TLS-Terminator. `websocket` transportiert alle logischen Streams über eine geordnete RFC-6455-Verbindung, während `websocket-lanes` jedem Stream ungleich null eine eigene Verbindung zuweist und Lane-Fehler isoliert. Beide WebSocket-Carrier verwenden nach der HTTPS-Sitzungserstellung `GET /api/v1/ws` und erfordern, dass der TLS-Terminator die HTTP/1.1-Upgrade-Header unverändert weiterleitet.
`enabled = true` erfordert mindestens einen durch die Netzwerkrichtlinie zugelassenen WEB-Listener, einen vhost und mindestens ein Profil in jedem vhost. `https` verwendet eine gemeinsame HTTPS-Uplink-Sequenz mit einem ausgehandelten Upload-Fenster von 1–4 und erfordert `max_http_handlers >= 2`. Mit `https-lanes` erhalten Stream null und jeder logische Stream eigene Uplink-Sequenzen, Downlink-Cursor, Wiederholungen und Long Polls; dieser Carrier erfordert `max_http_handlers >= 4` und öffentliches HTTP/2 am TLS-Terminator. `websocket` transportiert alle logischen Streams über eine geordnete RFC-6455-Verbindung, während `websocket-lanes` jedem Stream ungleich null eine eigene Verbindung zuweist und Lane-Fehler isoliert. Beide WebSocket-Carrier verwenden nach der HTTPS-Sitzungserstellung `GET /api/v1/ws` und erfordern, dass der TLS-Terminator die HTTP/1.1-Upgrade-Header unverändert weiterleitet.
Fehlt `carriers` oder ist es `false`, sind Auto-Negotiation und Lernen deaktiviert und `carrier` ist der einzige Modus. Ein nicht leeres `carriers`-Array aktiviert die Start-Negotiation in der konfigurierten Reihenfolge; `carrier` wird genau einmal als letzter Fallback angehängt. Leere Arrays, Duplikate und `true` werden abgelehnt. Der Client darf nur vor dem Carrier-Commit zum nächsten Kandidaten wechseln; nach dem Commit erfordert ein Carrier-Wechsel eine neue Sitzung. Ein nativer Client ohne Metadaten, einschließlich Telegram iOS, verwendet immer den konfigurierten festen `carrier`, auch bei aktivierter Negotiation. Das aktuelle iOS unterstützt nur `https`; solche Bereitstellungen müssen daher `carrier = "https"` setzen. Die CFNetwork- und Darwin-User-Agent-Klassifizierung leitet keine Carrier-Unterstützung ab. Explizite native iOS-Capabilities werden mit `{https}` geschnitten; andere explizite Client-Capabilities gelten wie gemeldet.
`http_connection_capacity_action` gilt erst, nachdem Telemt eine private WEB-TCP-Verbindung akzeptiert hat und `max_http_connections` ausgeschöpft ist. `drop` schließt wie bisher sofort. `respond` sendet eine leere wiederholbare `503 Service Unavailable` mit `Retry-After: 1`, `Cache-Control: no-store` und `Connection: close`. `wait` wartet höchstens `http_overload_timeout_ms` auf normale Kapazität und beginnt danach die übliche HTTP-Verarbeitung; bei Timeout wird dieselbe begrenzte `503` gesendet. Höchstens `max_http_overload_connections` akzeptierte Sockets dürfen außerhalb der normalen Kapazität warten oder antworten.
`http_connection_capacity_action` gilt erst, nachdem Telemt eine private WEB-TCP-Verbindung akzeptiert hat und `max_http_connections` ausgeschöpft ist. `drop` schließt wie bisher sofort. `respond` sendet eine leere wiederholbare `503 Service Unavailable` mit `Retry-After: 1`, `X-Telemt-Retry-After-Ms: 250`, `Cache-Control: no-store` und `Connection: close`. `wait` wartet höchstens `http_overload_timeout_ms` auf normale Kapazität und beginnt danach die übliche HTTP-Verarbeitung; bei Timeout wird dieselbe begrenzte `503` gesendet. Höchstens `max_http_overload_connections` akzeptierte Sockets dürfen außerhalb der normalen Kapazität warten oder antworten.
`decoy_fasttrack_mode` ist restart-only und betrifft nur Capability-Arbeit für `GET/HEAD` am konfigurierten Basis-Root. `off` behält den vollständigen Scan bei, `shadow` zählt geeignete Requests ohne den Scan zu überspringen, und `enforce` überspringt ihn nur für `HEAD` oder eine fehlende/nicht kanonische `bridge`-Query. Ein kanonisch geformtes Bridge-`GET` scannt immer alle Profile des ausgewählten vhost. Die Optimierung begrenzt keine feindlichen kanonischen Probes; `enforce` muss hinter dem produktiven TLS-Terminator auf Timing-Unterscheidbarkeit geprüft werden.
@@ -2704,6 +2704,38 @@ Diese prozessweiten Obergrenzen begrenzen alle WEB-Register, Warteschlangen, Req
| `new_streams_per_minute` | `u32` | `6000` | Nachhaltige Erstellungsrate für logische Streams. |
| `new_streams_burst` | `u32` | `512` | Prozessweiter Burst für die Stream-Erstellung. |
# [web.retry_jitter]
| Schlüssel | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | Aktiviert additiven Retry-Jitter. |
| `min_ms` | `u32` | `0` | Inklusiver minimaler Zuschlag in Millisekunden. |
| `max_ms` | `u32` | `250` | Inklusiver maximaler Zuschlag in Millisekunden; höchstens `30000`. |
| `distribution` | `string` | `"uniform"` | `uniform` oder `triangular`. |
| `peak_ms` | `Option<u32>` | `—` | Spitze der Dreiecksverteilung; ohne Angabe die Intervallmitte. |
`[web.retry_jitter]` ergänzt jeden bestehenden HTTP-Retry der Bridge um eine unabhängig gewählte Verzögerung: Netzwerkfehler und Antworten mit 408/429/502/503/504. Telegram-Anwendungen müssen nicht geändert werden. Ohne diesen Abschnitt oder mit `enabled = false` bleiben der 250-ms-Hinweis von Telemt und das bisherige Backoff/Jitter-Verhalten erhalten.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Der Bereich beschreibt einen **Zuschlag**, nicht die gesamte Pause: Zusammen mit dem Telemt-Hinweis von 250 ms ergibt dieses Beispiel 250–500 ms. Ohne positiven Serverhinweis bleibt die Basis bei 250, 500, 1000, dann 2000 ms. Aktivierter Jitter ersetzt den bisherigen Jitter von bis zu 25%. Ein gültiger standardmäßiger `Retry-After` wird im aktivierten Modus nicht auf die bisherige Grenze von 30 Sekunden verkürzt. Übersteigt die Pause das verbleibende Operationsbudget, läuft das Budget ohne vorzeitigen Retry ab.
Standardwerte: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, ohne `peak_ms`. Es gilt `0 <= min_ms <= max_ms <= 30000`. `uniform` verteilt ganze Millisekunden gleichmäßig; `triangular` verwendet eine Dreiecksverteilung mit Rundung auf Millisekunden. Der optionale Gipfel liegt standardmäßig in der Mitte und muss innerhalb des Intervalls liegen. Ein expliziter Gipfel bei `uniform`, unbekannte Schlüssel/Verteilungen und ungültige Grenzen werden auch im deaktivierten Zustand abgelehnt. Gleiche Grenzen ergeben einen konstanten Zuschlag.
Die Richtlinie wird pro Bridge-Seite festgeschrieben. Konfigurations-/API-Reload wirkt auf neu geladene Seiten; bestehende Seiten behalten ihre Einstellungen bei Recovery. Recovery-JSON und HTTP-Kompatibilitätsheader bleiben unverändert. Die Auswahl bevorzugt `crypto.getRandomValues`, mit `Math.random` als Kompatibilitätsfallback. Erfolgreicher Datenverkehr wird nicht verzögert. Versuchslimits, absolute Budgets, Abbruch und unveränderte Payload-/Sequence-Wiederholung bleiben erhalten; längere Pausen können die Anzahl der Versuche innerhalb eines Startup-/Recovery-Budgets reduzieren.
Dies streut feste Retry-Intervalle, garantiert aber keinen Schutz vor DPI. Browser-Scheduling, Netzwerkwarteschlangen und andere Protokollzeiten bleiben beobachtbar. Bestehende Zeitleisten zeigen den deaktivierten Standardmodus. Bei aktiviertem Modus wird zu jeder Retry-Pause eine gezogene Verzögerung addiert, nicht zum ersten Verbindungsaufbau oder zu jedem Datenframe.
Die Richtlinie ändert nur bereits vorhandene Retry-Pausen. Strengere Limits einzelner Aufrufe bleiben erhalten: Reguläre `/down`-Polls und `/up`-Aufrufe nach dem Commit führen einen Versuch aus; ihr Recovery-Replay erlaubt zwei. Die Option erzeugt keine neuen Retry-Schleifen. Sie verlängert weder `web.timeouts.bridge_retry_secs` noch Start- oder Recovery-Deadlines.
# [web.timeouts]
Sofern eine Zeile nichts anderes angibt, werden Timeouts in Sekunden angegeben und müssen im Bereich `1..=3600` liegen. Konfigurierte serverseitige Deadlines einzelner HTTP-Phasen müssen kleiner als `http_idle_secs` sein; geschützte Phasen behalten ihre eigenen Deadlines, sodass der Idle-Timer keine Gesamtdeadline für einen Request ist. Das clientseitige Bridge-Retry-Fenster hat eigene Grenzen.
+34 -2
View File
@@ -2616,11 +2616,11 @@ WEB mode carries Telegram Desktop MTProxy traffic through HTTPS terminated by an
| `timeouts` | table | bounded defaults | `✔` |
| `vhosts` | array of tables | `[]` | `✔` |
`enabled = true` requires at least one network-eligible WEB listener, at least one vhost, and at least one profile in every vhost. `https` preserves the serialized HTTPS transport and requires `max_http_handlers >= 2`. `https-lanes` gives stream zero and every logical stream independent uplink sequencing, downlink cursors, retries, and long polls; it requires `max_http_handlers >= 4` and public HTTP/2 on the TLS terminator. `websocket` carries all logical streams over one ordered RFC 6455 connection, while `websocket-lanes` owns one connection per non-zero logical stream and isolates lane failures. Both WebSocket carriers use `GET /api/v1/ws` after HTTPS session creation and require the TLS terminator to preserve HTTP/1.1 Upgrade headers.
`enabled = true` requires at least one network-eligible WEB listener, at least one vhost, and at least one profile in every vhost. `https` uses a shared HTTPS uplink sequence with a negotiated upload window of 1–4 and requires `max_http_handlers >= 2`. `https-lanes` gives stream zero and every logical stream independent uplink sequencing, downlink cursors, retries, and long polls; it requires `max_http_handlers >= 4` and public HTTP/2 on the TLS terminator. `websocket` carries all logical streams over one ordered RFC 6455 connection, while `websocket-lanes` owns one connection per non-zero logical stream and isolates lane failures. Both WebSocket carriers use `GET /api/v1/ws` after HTTPS session creation and require the TLS terminator to preserve HTTP/1.1 Upgrade headers.
When `carriers` is missing or `false`, auto-negotiation and learning are disabled and `carrier` is the only mode. A non-empty `carriers` array enables startup-only negotiation in its configured order; `carrier` is appended exactly once as the final fallback. Empty arrays, duplicates, and `true` are rejected. The client advances candidates only before carrier commit and must create a new session to change carrier after commit. A metadata-free native client, including Telegram iOS, always uses the configured fixed `carrier`, even when negotiation is enabled. Current iOS supports only `https`, so such deployments must configure `carrier = "https"`. CFNetwork and Darwin User-Agent classification does not infer carrier support. Explicit native iOS capabilities are intersected with `{https}`; other explicit client capabilities participate as reported.
`http_connection_capacity_action` applies only after Telemt has accepted a private WEB TCP connection and `max_http_connections` is exhausted. `drop` preserves the legacy immediate close. `respond` emits an empty `503 Service Unavailable` with `Retry-After: 1`, `Cache-Control: no-store`, and `Connection: close`. `wait` waits for ordinary connection capacity for at most `http_overload_timeout_ms`, then enters normal HTTP handling; timeout emits the same bounded `503`. At most `max_http_overload_connections` accepted sockets may wait or respond outside ordinary connection capacity. This policy cannot observe or cause a TCP connect refusal before Telemt accepts the socket.
`http_connection_capacity_action` applies only after Telemt has accepted a private WEB TCP connection and `max_http_connections` is exhausted. `drop` preserves the legacy immediate close. `respond` emits an empty `503 Service Unavailable` with `Retry-After: 1`, `X-Telemt-Retry-After-Ms: 250`, `Cache-Control: no-store`, and `Connection: close`. `wait` waits for ordinary connection capacity for at most `http_overload_timeout_ms`, then enters normal HTTP handling; timeout emits the same bounded `503`. At most `max_http_overload_connections` accepted sockets may wait or respond outside ordinary connection capacity. This policy cannot observe or cause a TCP connect refusal before Telemt accepts the socket.
`decoy_fasttrack_mode` is restart-only. `off` preserves legacy root-request scanning and collects no fast-track decisions. `shadow` records eligible requests while preserving the full scan. `enforce` skips scans only for `HEAD` or absent/noncanonical bridge queries. A canonical bridge-shaped `GET`, including an unknown capability, always scans every profile in the selected vhost. The optimization does not bound hostile canonical probes and enforce mode must be validated for request-shape timing distinguishability behind the production TLS terminator.
@@ -2704,6 +2704,38 @@ These process-wide ceilings make every WEB registry, queue, request body, capabi
| `new_streams_per_minute` | `u32` | `6000` | Sustained logical-stream creation rate. |
| `new_streams_burst` | `u32` | `512` | Process-wide logical-stream creation burst. |
# [web.retry_jitter]
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | Enable additive retry jitter. |
| `min_ms` | `u32` | `0` | Inclusive minimum addition in milliseconds. |
| `max_ms` | `u32` | `250` | Inclusive maximum addition in milliseconds; at most `30000`. |
| `distribution` | `string` | `"uniform"` | `uniform` or `triangular`. |
| `peak_ms` | `Option<u32>` | `—` | Triangular peak; omitted means the interval midpoint. |
`[web.retry_jitter]` adds an independently sampled delay to every existing bridge HTTP retry (network errors and 408/429/502/503/504). It requires no Telegram application changes. Without this section, or with `enabled = false`, the existing 250 ms Telemt hint and legacy backoff/jitter remain unchanged.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
The range describes an **addition**, not the total pause: the Telemt 250 ms hint plus this example gives 250–500 ms. Without a positive server hint, the base remains 250, 500, 1000, then 2000 ms. Enabled jitter replaces the legacy 25% jitter. A valid standard `Retry-After` is not shortened to the legacy 30-second cap in enabled mode. If the pause exceeds the remaining operation budget, the budget expires without an early retry.
Defaults are `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, and no `peak_ms`. Require `0 <= min_ms <= max_ms <= 30000`. `uniform` samples integer milliseconds; `triangular` samples a triangular density and rounds to milliseconds. Its optional peak defaults to the midpoint and must be within the interval. An explicit peak with `uniform`, unknown keys/distributions and invalid ranges are rejected even when disabled. Equal bounds produce a constant addition.
The policy is frozen into each bridge page. Config/API reload affects newly loaded pages; existing pages retain it through recovery. Recovery JSON and HTTP compatibility headers remain unchanged. Sampling prefers `crypto.getRandomValues`, with `Math.random` as a compatibility fallback. Successful traffic is not delayed. Attempt limits, absolute budgets, cancellation and payload/sequence replay remain intact; longer pauses can exhaust a startup/recovery budget sooner in terms of completed attempts.
This reduces fixed retry timing, without guaranteeing resistance to DPI. Browser scheduling, network queues and other protocol timing remain observable. Existing timeline examples describe the default disabled mode. To evaluate enabled mode, add a sampled delay to each retry pause, not to the initial connection or every data frame.
The policy changes existing retry waits only. Stricter per-call limits still apply: ordinary `/down` polls and committed `/up` calls use one attempt; their recovery replay permits two. This option does not create new retry loops. It neither extends `web.timeouts.bridge_retry_secs` nor any startup/recovery deadline.
# [web.timeouts]
Unless a row states otherwise, timeouts are measured in seconds and must be within `1..=3600`. Configured server-side HTTP phase deadlines must be lower than `http_idle_secs`; protected phases retain their own deadlines, so the idle timer is not an aggregate request deadline. The bridge retry window is client-side and follows its own bound.
+34 -2
View File
@@ -2622,11 +2622,11 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут
| `timeouts` | таблица | ограниченные defaults | `✔` |
| `vhosts` | массив таблиц | `[]` | `✔` |
Для `enabled = true` нужен как минимум один доступный по сетевой политике WEB-listener, один vhost и один профиль в каждом vhost. `https` сохраняет сериализованный HTTPS transport и требует `max_http_handlers >= 2`. В `https-lanes` stream zero и каждый logical stream получают независимые uplink sequence, downlink cursor, retry и long poll; carrier требует `max_http_handlers >= 4` и публичного HTTP/2 на TLS-терминаторе. `websocket` переносит все logical streams через одно упорядоченное RFC 6455 connection, а `websocket-lanes` выделяет отдельное connection каждому ненулевому stream и изолирует сбои lane. Оба WebSocket carrier используют `GET /api/v1/ws` после создания HTTPS-сессии и требуют от TLS-терминатора сохранять HTTP/1.1 Upgrade headers.
Для `enabled = true` нужен как минимум один доступный по сетевой политике WEB-listener, один vhost и один профиль в каждом vhost. `https` использует общую последовательность HTTPS uplink с согласуемым окном upload 1–4 и требует `max_http_handlers >= 2`. В `https-lanes` stream zero и каждый logical stream получают независимые uplink sequence, downlink cursor, retry и long poll; carrier требует `max_http_handlers >= 4` и публичного HTTP/2 на TLS-терминаторе. `websocket` переносит все logical streams через одно упорядоченное RFC 6455 connection, а `websocket-lanes` выделяет отдельное connection каждому ненулевому stream и изолирует сбои lane. Оба WebSocket carrier используют `GET /api/v1/ws` после создания HTTPS-сессии и требуют от TLS-терминатора сохранять HTTP/1.1 Upgrade headers.
Если `carriers` отсутствует или равен `false`, auto-negotiation и обучение выключены, а `carrier` задаёт единственный режим. Непустой массив `carriers` включает стартовый перебор в заданном порядке; `carrier` ровно один раз добавляется последним fallback-вариантом. Пустой массив, дубликаты и `true` запрещены. Клиент может перейти к следующему кандидату только до commit carrier; для смены carrier после commit нужна новая сессия. Native-клиент без метаданных, включая Telegram iOS, всегда использует настроенный фиксированный `carrier`, даже при включённом auto-negotiation. Текущий iOS поддерживает только `https`, поэтому такой deployment должен задавать `carrier = "https"`. Классификация User-Agent CFNetwork и Darwin не определяет поддержку carrier. Явные capabilities нативного iOS пересекаются с `{https}`; capabilities остальных явных клиентов применяются как переданы.
`http_connection_capacity_action` применяется только после того, как Telemt принял приватное WEB TCP connection и исчерпал `max_http_connections`. `drop` сохраняет немедленное закрытие. `respond` отправляет пустой повторяемый `503 Service Unavailable` с `Retry-After: 1`, `Cache-Control: no-store` и `Connection: close`. `wait` ожидает обычную capacity не более `http_overload_timeout_ms`, затем переходит к нормальной HTTP-обработке; по timeout отправляется тот же ограниченный `503`. Вне обычной capacity могут ожидать или отвечать не более `max_http_overload_connections` принятых sockets.
`http_connection_capacity_action` применяется только после того, как Telemt принял приватное WEB TCP connection и исчерпал `max_http_connections`. `drop` сохраняет немедленное закрытие. `respond` отправляет пустой повторяемый `503 Service Unavailable` с `Retry-After: 1`, `X-Telemt-Retry-After-Ms: 250`, `Cache-Control: no-store` и `Connection: close`. `wait` ожидает обычную capacity не более `http_overload_timeout_ms`, затем переходит к нормальной HTTP-обработке; по timeout отправляется тот же ограниченный `503`. Вне обычной capacity могут ожидать или отвечать не более `max_http_overload_connections` принятых sockets.
`decoy_fasttrack_mode` требует перезапуска и управляет только capability work для `GET/HEAD` на настроенном base root. `off` сохраняет полный scan, `shadow` считает подходящие requests, но не пропускает scan, а `enforce` пропускает его только для `HEAD` или отсутствующего/неканонического параметра `bridge`. Канонический bridge-shaped `GET` всегда сканирует все профили выбранного vhost. Оптимизация не ограничивает враждебные канонические probes; `enforce` необходимо проверять на различимость timing за production TLS-терминатором.
@@ -2710,6 +2710,38 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут
| `new_streams_per_minute` | `u32` | `6000` | Устойчивая скорость создания logical streams. |
| `new_streams_burst` | `u32` | `512` | Process-wide burst создания logical streams. |
# [web.retry_jitter]
| Ключ | Тип | По умолчанию | Описание |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | Включает добавочный jitter повторов. |
| `min_ms` | `u32` | `0` | Минимальная добавка в миллисекундах включительно. |
| `max_ms` | `u32` | `250` | Максимальная добавка в миллисекундах включительно; не более `30000`. |
| `distribution` | `string` | `"uniform"` | `uniform` или `triangular`. |
| `peak_ms` | `Option<u32>` | `—` | Пик треугольного распределения; при отсутствии — середина диапазона. |
`[web.retry_jitter]` добавляет независимо выбранную задержку к каждому существующему HTTP-повтору бриджа: сетевым ошибкам и ответам 408/429/502/503/504. Менять приложения Telegram не требуется. Без секции или при `enabled = false` сохраняются подсказка Telemt на 250 мс и прежний backoff/jitter.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Диапазон задаёт **добавку**, а не полную паузу: подсказка Telemt 250 мс с этим примером даёт 250–500 мс. Без положительной серверной подсказки база остаётся 250, 500, 1000, затем 2000 мс. Включённый jitter заменяет прежний jitter до 25%. Корректный стандартный `Retry-After` во включённом режиме не сокращается прежним пределом в 30 секунд. Если пауза не помещается в оставшийся бюджет операции, бюджет истекает без раннего повтора.
Значения по умолчанию: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, `peak_ms` отсутствует. Требуется `0 <= min_ms <= max_ms <= 30000`. `uniform` выбирает целые миллисекунды равномерно; `triangular` использует треугольную плотность с округлением до миллисекунд. Необязательный пик по умолчанию находится посередине и должен входить в диапазон. Явный пик для `uniform`, неизвестные ключи/распределения и неверные границы отклоняются даже при выключенной опции. Одинаковые границы дают постоянную добавку.
Политика фиксируется в каждой странице бриджа. Reload через конфиг/API влияет на новые страницы; активные сохраняют настройки при recovery. Recovery JSON и HTTP-заголовки совместимости не меняются. Используется `crypto.getRandomValues`, при недоступности — `Math.random` для совместимости. Успешный трафик не задерживается. Лимиты попыток, общий бюджет, отмена и повтор неизменных payload/sequence сохраняются; более длинные паузы могут уменьшить число попыток, помещающихся в startup/recovery-бюджет.
Это размывает фиксированные интервалы повторов, но не гарантирует защиту от DPI. Планирование браузера, сетевые очереди и другие временные характеристики протокола остаются наблюдаемыми. Существующие примеры временной шкалы описывают выключенную опцию. Для включённого режима добавляйте выбранную задержку к каждой паузе повтора, а не к первому соединению или каждому кадру данных.
Политика меняет только уже предусмотренные паузы перед повтором. Более строгие ограничения отдельных вызовов сохраняются: обычный poll `/down` и вызов `/up` после commit выполняют одну попытку; их replay при восстановлении допускает две. Опция не создаёт новых циклов повторов. Она не продлевает `web.timeouts.bridge_retry_secs` и сроки запуска или восстановления.
# [web.timeouts]
Если в строке не указано иное, таймауты задаются в секундах и должны входить в диапазон `1..=3600`. Настроенные серверные deadlines отдельных HTTP-фаз должны быть меньше `http_idle_secs`; защищённые фазы сохраняют собственные deadlines, поэтому idle-таймер не является общим deadline запроса. Client-side окно повторов bridge имеет отдельные границы.
+244
View File
@@ -0,0 +1,244 @@
# Ping in der Proxyliste: RPC Ping/Pong und WEB-Latenz
[English](PROXY_PING.en.md) | [Русский](PROXY_PING.ru.md) | [Deutsch](PROXY_PING.de.md)
Die beobachteten **1000–1500 ms für WEB gegenüber 50–150 ms für Classic/DD/EE** können auf unterschiedlichen Messgrenzen beruhen. Bei der WEB-Prüfung unter Android ist der lokale Socket bereits bereit, bevor der entfernte WEB-Stream zwingend bereit ist. Der verbleibende Streamaufbau und die Übertragung können deshalb in den angezeigten Wert eingehen. Der Code schreibt keinen festen Zuschlag von einer Sekunde für jeden erfolgreichen WEB-Ping vor.
Diese Zahlen stammen aus Betreiberbeobachtungen und wurden für dieses Dokument nicht reproduziert. Die Quellcodeanalyse belegt Mechanismen, aber nicht die Ursache einer Verzögerung in einer bestimmten Installation.
## Clients und Messungen
Geprüft am **2026-10-31** anhand von DrKLO Android `3de37c8cfb039452be7398909461da6288babf15` und Telegram iOS `279f89e4d4f63f17227bf877dc3d8990f22ecd58`. Installierte Versionen und Forks können abweichen.
| Messung | Erfasster Bereich | Verhalten bei WEB |
| --- | --- | --- |
| Ping in der Android-Proxyliste | Verschlüsseltes MTProto `TL_ping` / `TL_pong` über den geprüften Proxy | Separater WEB-Testtransport, anschließend native Prüfung über einen lokalen Socket. |
| Ping in der iOS-Proxyliste | Erreichbarkeitsprüfung mit `req_pq` / `resPQ`; Messbeginn bei `connectionOpened` | `.web`-Einträge sind von der Ping-Prüfung ausgeschlossen; ein inaktiver WEB-Eintrag erscheint als nicht getestet. |
| ICMP-Ping | IP-Echo zum gewählten Host unter den jeweiligen ICMP-Routingregeln | Misst weder die Anwendungsweiterleitung noch das Telegram-DC. |
| WEB-Transport `FRAME_PING` / `FRAME_PONG` oder WebSocket-Keepalive | Erreichbarkeit des Transports bzw. der Sitzung | Nicht das in Android angezeigte MTProto-Ergebnis. |
| Telemt ME RPC Ping/Pong | Ein Writer-Pfad von Telemt zum ME einschließlich lokaler Warteschlange und Flush-Planung | Ohne Client, WebView und öffentliches TLS-Frontend. |
Quellen: [Android-Einstieg][android-entry], [native Anfragen][android-native], [iOS-Filter][ios-filter], [iOS-Prüfung][ios-probe], [iOS-Oberfläche][ios-ui]. Die Android-Formel für WEB-Ping lässt sich somit nicht auf den WEB-Eintrag im geprüften iOS übertragen.
Android zeigt das letzte Prüfergebnis ohne Median oder EMA an und überspringt eine erneute Prüfung, wenn die vorherige weniger als zwei Minuten zurückliegt. Auch ein verbundener Proxy kann diesen gespeicherten Wert anzeigen: Er ist keine laufende RTT-Messung der aktiven Sitzung. Die WEB-Prüfung erstellt einen eigenen Transport, statt den aktiven Anwendungstransport direkt wiederzuverwenden. Siehe [Aktualisierung der Proxyliste][android-ui] und [WEB-Transport][android-transport].
## Beginn und Ende der Android-Messung
Bei Classic/DD/EE erfolgt der native Bereitschafts-Callback nach dem entfernten TCP-Verbindungsaufbau und der erforderlichen Einrichtung des Proxytransports, einschließlich EE FakeTLS. Die Anfrageverarbeitung aktualisiert den Zeitstempel vor dem Senden bzw. erneuten Senden der Prüfung. Die übliche erfolgreiche Messung schließt diesen anfänglichen Transportaufbau daher aus, erfasst aber die anschließende Weiterleitung und den Rückweg. Siehe [Socket-Bereitschaft][android-socket] und [Anfrageverarbeitung][android-native].
Bei WEB liegen der Bridge-Start, das anfängliche Laden über HTTPS, HELLO/WELCOME und weitere **500 ms** Wartezeit vor `native_checkProxy`; diese Wartezeit wird nicht zum angezeigten Ping addiert. WELCOME bestätigt eine WEB-Sitzung, keinen bereiten Stream zum Telegram-DC. Anschließend verbindet sich die native Prüfung mit `127.0.0.1`. Dessen Bereitschaft wartet nicht auf den Abschluss des entfernten OPEN. Java-Accept/OPEN und die Aktualisierung des nativen Zeitstempels können in unterschiedlicher Reihenfolge erfolgen. Nur die innerhalb des Messintervalls noch ausstehende Arbeit trägt zum Ergebnis bei. Siehe [Prüfablauf][android-tester], [lokaler Socket und WELCOME][android-transport], [serverseitige Sitzungserstellung][session] und [Stream-Backend][backend].
Die Diagramme zeigen den üblichen erfolgreichen Ablauf, garantieren jedoch keine threadübergreifende Ereignisreihenfolge:
```mermaid
sequenceDiagram
participant C as Android native checker
participant P as Classic/DD/EE proxy
participant D as Telegram DC
C->>P: DNS / TCP / proxy transport setup
Note over C,P: Remote transport ready, measurement timestamp refreshed
C->>P: Encrypted TL_ping
P->>D: Relay
D->>P: TL_pong
P->>C: Relay
Note over C: Display elapsed time from latest request timestamp
```
```mermaid
sequenceDiagram
participant C as Android native checker
participant B as Local socket / Java / WebView
participant T as TLS frontend / Telemt
participant D as Telegram DC
B->>T: Bridge load, HELLO / session creation
T->>B: WELCOME
Note over B: Wait 500 ms before native_checkProxy
C->>B: Connect to 127.0.0.1
Note over C,B: Local socket ready, measurement timestamp refreshed
Note over B,T: OPEN may race with the native timestamp update
C->>B: MTProxy header and encrypted TL_ping
B->>T: OPEN / DATA over selected carrier
T->>D: Stream setup and relay via Direct / ME
D->>T: TL_pong
T->>B: Downlink DATA
B->>C: Local socket delivery
Note over C: Display elapsed time from latest request timestamp
```
`TL_pong` beendet die Messung relativ zum letzten Anfragezeitstempel. Erzwungene Verarbeitung oder erneutes Senden können diesen zurücksetzen. Der angezeigte Wert entspricht daher nicht zwingend der gesamten Dauer eines fehlgeschlagenen oder wiederholten Versuchs. Die Prüfung verwendet einen [vorhandenen DC-Autorisierungsschlüssel][android-key]; ein vollständiger Austausch zur Erstellung eines neuen MTProto-Schlüssels gehört nicht obligatorisch zu jeder Prüfung.
## Einfluss von TLS
Die öffentliche TLS-Verbindung endet bei NGINX, HAProxy oder Caddy. Telemt empfängt unverschlüsseltes HTTP/1.1 am privaten Listener. Öffentliches HTTP/2 und internes HTTP/1.1 sind getrennte Verbindungen; ihre Protokollversionen müssen nicht übereinstimmen. EE FakeTLS ist ein MTProxy-Tarntransport, nicht die HTTPS-Verbindung des Browsers mit Zertifikatsprüfung. WEB transportiert plain/DD-MTProxy; EE ist kein WEB-Secret-Format. Siehe [WEB-Betriebsanleitung](WEB_PROXY.de.md).
Bei einer neuen Verbindung ohne Paketverlust benötigt der übliche TCP-Handshake ungefähr einen RTT zwischen Client und Frontend. Ein vollständiger TLS-1.3-Handshake fügt normalerweise einen RTT hinzu, ein vollständiger TLS-1.2-Handshake zwei. Das sind Schätzungen der Protokollrunden ohne DNS, Verarbeitung, Zertifikatsprüfung, Scheduling und erneute Übertragungen, kein gemessenes WEB-Latenzbudget. Sitzungswiederaufnahme kann den Ablauf verändern. Siehe [TLS 1.3][tls13] und [TLS 1.2][tls12].
Das anfängliche TLS für die Bridge liegt normalerweise vor der Android-Messung. Zusätzliche HTTP-Verbindungen oder ein WebSocket-Upgrade können noch innerhalb des Intervalls stattfinden; Verbindungswiederverwendung muss anhand eines Traces belegt werden. Persistente HTTPS-Verbindungen und bestehende WebSockets führen nicht für jeden Ping einen neuen TLS-Handshake aus. `cache: 'no-store'` steuert Caching, nicht die Wiederverwendung von TCP/TLS. Weder das Abschalten der Zertifikatsprüfung noch TLS 0-RTT ist zur Erklärung oder Diagnose erforderlich. Siehe [persistente HTTP-Verbindungen][http1] und [Bridge-Anfragen][request].
## Zusätzliche Arbeit bei WEB gegenüber nativem MTProxy
Die Darstellung trennt Arbeit vor dem Android-Timer von Arbeit, die in den angezeigten Wert eingehen kann. Als Basis dient Classic/DD über TCP. Bei EE gehört FakeTLS zum Aufbau des nativen Transports. Die Größe der Kästen und die Länge der Pfeile stellen keine Zeitdauer dar.
```mermaid
flowchart LR
subgraph N["Natives MTProxy: Classic/DD"]
direction TB
N0["Vor der Messung<br/>DNS + TCP + nativer Transportaufbau"] --> N1["Entfernter Transport bereit<br/>Anfragezeitstempel aktualisiert"]
N1 --> N2["MTProxy-Anfrage und Antwort<br/>Ausstehender Relay-Aufbau, Netzwerk, Warteschlangen, Telegram-DC"] --> N3["TL_pong empfangen<br/>Angezeigte Messung beendet"]
end
subgraph W["WEB: MTProxy in HTTPS oder WebSocket"]
direction TB
W0["Vor der Messung<br/>DNS + TCP + echtes TLS<br/>HTTP-Bridge + HELLO/WELCOME + 500 ms"] --> W1["Lokaler Socket bereit<br/>Anfragezeitstempel aktualisiert"]
W1 --> W2["Zusätzliche Client-Arbeit<br/>Java / WebView / JavaScript-Warteschlangen<br/>Entferntes OPEN eventuell noch ausstehend"] --> W3["Uplink-Verarbeitung<br/>HTTP-Upload oder WebSocket-Frame<br/>Frontend-Verarbeitung und mögliche Pufferung"]
W3 --> W4["Innere MTProxy-Anfrage und Antwort<br/>Ausstehender Relay-Aufbau, Netzwerk, Warteschlangen, Telegram-DC"] --> W5["Downlink-Verarbeitung<br/>HTTP-Poll oder WebSocket-Frame<br/>ACK/Commit-Freigabe und native Zustellwarteschlangen"]
W5 --> W6["TL_pong empfangen<br/>Angezeigte Messung beendet"]
X["Nur wenn eine weitere Verbindung nötig ist<br/>TCP + TLS, eventuell WebSocket-Upgrade<br/>Kein Handshake pro Ping bei Wiederverwendung"] -.-> W3
X -.-> W5
R["Nur bei Fehlern oder Überlastung<br/>Retries und Backoff<br/>Telemt-Basis 250 ms<br/>Plus optionaler Jitter"] -.-> W3
R -.-> W5
end
classDef preparation fill:#eeeeee,stroke:#666666,color:#111111
classDef boundary fill:#dbeafe,stroke:#2563eb,color:#111111
classDef shared fill:#dcfce7,stroke:#15803d,color:#111111
classDef extra fill:#ffedd5,stroke:#c2410c,color:#111111
class N0,W0 preparation
class N1,N3,W1,W6 boundary
class N2,W4 shared
class W2,W3,W5,X,R extra
```
**Grau:** Arbeit vor der angezeigten Messung. **Blau:** Timer-Grenzen. **Grün:** MTProxy-Relay/DC-Arbeit in beiden Pfaden, deren tatsächliche Dauer abweichen kann. **Orange:** WEB-Transport- und Client-Arbeit innerhalb der Messung. **Gestrichelte Pfeile:** bedingter Verbindungsaufbau oder Retries, keine obligatorischen Phasen.
Bei Wiederverwendung des Carriers entfällt der bedingte TCP/TLS/Upgrade-Aufwand. Framing, HTTP- oder WebSocket-Verarbeitung und Client-Warteschlangen bleiben bestehen. HTTP kann Up- und Downlink überlappen, sodass nicht jede Anfrage zwingend einen ganzen RTT hinzufügt. Polling wird bei verfügbaren Daten geweckt, und Frontend-Pufferung hängt von der Installation ab. Paketverlust, Netzwerküberlastung und Upstream-Aufbau können auch natives MTProxy verzögern.
Die sichtbare Differenz kann somit verbleibenden WEB-Streamaufbau, zusätzliche Verbindungen, Transportverarbeitung und Scheduling sowie belegte Retries enthalten. Sie ist weder ein fester TLS-Zuschlag noch die arithmetische Summe aller Kästen. Der erste echte TLS-Handshake steht bewusst vor dem Android-Timer: Er beeinflusst die Gesamtdauer der Prüfung, normalerweise aber nicht den angezeigten Messwert. Da die Prüfung einen separaten Transport verwendet, garantiert eine aktive App-Verbindung auch keine warme Prüfung in der Proxyliste.
### Konfigurierbarer Jitter für HTTP-Wiederholungen
`[web.retry_jitter]` ergänzt jeden bestehenden HTTP-Retry der Bridge um eine unabhängig gewählte Verzögerung: Netzwerkfehler und Antworten mit 408/429/502/503/504. Telegram-Anwendungen müssen nicht geändert werden. Ohne diesen Abschnitt oder mit `enabled = false` bleiben der 250-ms-Hinweis von Telemt und das bisherige Backoff/Jitter-Verhalten erhalten.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Der Bereich beschreibt einen **Zuschlag**, nicht die gesamte Pause: Zusammen mit dem Telemt-Hinweis von 250 ms ergibt dieses Beispiel 250–500 ms. Ohne positiven Serverhinweis bleibt die Basis bei 250, 500, 1000, dann 2000 ms. Aktivierter Jitter ersetzt den bisherigen Jitter von bis zu 25%. Ein gültiger standardmäßiger `Retry-After` wird im aktivierten Modus nicht auf die bisherige Grenze von 30 Sekunden verkürzt. Übersteigt die Pause das verbleibende Operationsbudget, läuft das Budget ohne vorzeitigen Retry ab.
Standardwerte: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, ohne `peak_ms`. Es gilt `0 <= min_ms <= max_ms <= 30000`. `uniform` verteilt ganze Millisekunden gleichmäßig; `triangular` verwendet eine Dreiecksverteilung mit Rundung auf Millisekunden. Der optionale Gipfel liegt standardmäßig in der Mitte und muss innerhalb des Intervalls liegen. Ein expliziter Gipfel bei `uniform`, unbekannte Schlüssel/Verteilungen und ungültige Grenzen werden auch im deaktivierten Zustand abgelehnt. Gleiche Grenzen ergeben einen konstanten Zuschlag.
Die Richtlinie wird pro Bridge-Seite festgeschrieben. Konfigurations-/API-Reload wirkt auf neu geladene Seiten; bestehende Seiten behalten ihre Einstellungen bei Recovery. Recovery-JSON und HTTP-Kompatibilitätsheader bleiben unverändert. Die Auswahl bevorzugt `crypto.getRandomValues`, mit `Math.random` als Kompatibilitätsfallback. Erfolgreicher Datenverkehr wird nicht verzögert. Versuchslimits, absolute Budgets, Abbruch und unveränderte Payload-/Sequence-Wiederholung bleiben erhalten; längere Pausen können die Anzahl der Versuche innerhalb eines Startup-/Recovery-Budgets reduzieren.
Dies streut feste Retry-Intervalle, garantiert aber keinen Schutz vor DPI. Browser-Scheduling, Netzwerkwarteschlangen und andere Protokollzeiten bleiben beobachtbar. Bestehende Zeitleisten zeigen den deaktivierten Standardmodus. Bei aktiviertem Modus wird zu jeder Retry-Pause eine gezogene Verzögerung addiert, nicht zum ersten Verbindungsaufbau oder zu jedem Datenframe.
Die Richtlinie ändert nur bereits vorhandene Retry-Pausen. Strengere Limits einzelner Aufrufe bleiben erhalten: Reguläre `/down`-Polls und `/up`-Aufrufe nach dem Commit führen einen Versuch aus; ihr Recovery-Replay erlaubt zwei. Die Option erzeugt keine neuen Retry-Schleifen. Sie verlängert weder `web.timeouts.bridge_retry_secs` noch Start- oder Recovery-Deadlines.
## Beispielhafte Zeitleiste mit ungefähren Dauern
**Ein Erklärungsmodell, kein Benchmark und kein erwarteter Latenzbereich.** Die Zahlen sollen das Zusammenspiel der Phasen zeigen. Sie rekonstruieren nicht die beobachteten 1000–1500 ms. Annahmen: DNS ist bereits zwischengespeichert, der RTT zwischen Client und Frontend beträgt 40 ms, die TCP-Verbindung ist neu, bei Bedarf erfolgt ein vollständiger TLS-1.3-Handshake, ein DC-Autorisierungsschlüssel ist vorhanden, und es gibt weder Paketverlust noch ein Zurücksetzen des nativen Anfragezeitstempels. Als Basis dient Classic/DD. Bei EE kommt der eigene FakeTLS-Aufbau vor dem nativen Timer hinzu.
In diesem Beispiel benötigt natives MTProxy 40 ms Vorbereitung für TCP. Die WEB-Vorbereitung dauert 750 ms: 50 ms Client-/Bridge-Start + 40 ms TCP + 40 ms TLS + 120 ms HTTP-Austausch für Bridge und Sitzung + 500 ms Wartezeit des Android-Checkers. Hier werden aufeinanderfolgende Dauern angenommen; reale Phasen können überlappen. Das anfängliche TLS und die 500 ms Wartezeit liegen außerhalb des angezeigten Pings.
Die Darstellung beginnt beim **letzten Anfragezeitstempel** der jeweiligen Prüfung, nicht beim App-Start oder ersten Verbindungsversuch. Die horizontale Achse zeigt Sekunden mit Millisekundengenauigkeit (0.200 = 200 ms). Die Balkenbreite ist proportional zur Dauer. Jeder Abschnitt ist ein separates Szenario; seine Phasen folgen aufeinander und laufen nicht parallel. Die anfängliche Vorbereitung liegt außerhalb der Darstellung.
```mermaid
%%{init: {"theme": "neutral", "gantt": {"leftPadding": 200, "rightPadding": 100, "barHeight": 24, "barGap": 8, "fontSize": 14, "sectionFontSize": 14}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 100millisecond
todayMarker off
title Angezeigter Ping - Jitter aus
accTitle: Angezeigter Ping - Jitter aus
accDescr: Sekunden seit Anfragezeitstempel, Beschriftungen in Millisekunden
section Classic/DD - 100 ms
R 100 ms :n_r, 0, 100ms
section WEB ohne Fehler - 200 ms
U 40 ms :w_u, 0, 40ms
R 100 ms :w_r, after w_u, 100ms
D 60 ms :w_d, after w_r, 60ms
section WEB mit Retry - 490 ms
F 40 ms :crit, f_u, 0, 40ms
Warten 250 ms :crit, retry_wait, after f_u, 250ms
U 40 ms :retry_u, after retry_wait, 40ms
R 100 ms :retry_r, after retry_u, 100ms
D 60 ms :retry_d, after retry_r, 60ms
```
Dauer der einzelnen Phasen: `R` = 100 ms grundlegende MTProxy-Relay-/Netzwerk-/DC-Arbeit für Anfrage und Antwort. `U` = 40 ms zusätzliche WEB-Uplink-/OPEN-/Client-Arbeit. `D` = 60 ms zusätzliche WEB-Downlink-/Zustellarbeit. `F` = 40 ms für einen fehlgeschlagenen Upload-Versuch. `Warten` = 250 ms Wartezeit gemäß `X-Telemt-Retry-After-Ms: 250`. `U` und `D` sind beispielhafte Zusatzkosten gegenüber `R`, keine erneute Anrechnung des gesamten Netzwerk-RTT. Die ACK/Commit-Freigabe wird nur berücksichtigt, soweit sie die Zustellung tatsächlich verzögert.
| Szenario | Vor dem Ping-Timer | Angezeigter Ping | Gesamtdauer der Prüfung im Modell |
| --- | --- | --- | --- |
| Classic/DD | 40 ms | 100 ms | 140 ms |
| WEB ohne Fehler | 750 ms | 40 + 100 + 60 = 200 ms | 950 ms |
| WEB mit einem fehlgeschlagenen Upload und einer Retry-Pause | 750 ms | 40 + 250 + 40 + 100 + 60 = 490 ms | 1240 ms |
| WEB, gleicher Retry mit uniformem Jitter 0–250 ms | 750 ms | 490–740 ms | 1240–1490 ms |
Eine weitere TCP- und TLS-1.3-Verbindung **innerhalb** der Messung könnte hier bei vollständig sequenziellem Ablauf ungefähr 80 ms hinzufügen: Aus 200 ms würden 280 ms. Bei Verbindungswiederverwendung entfällt dieser zusätzliche Handshake-Aufwand. Ein anfänglicher vollständiger TLS-1.2- statt TLS-1.3-Handshake verlängert unter diesen Annahmen die Vorbereitung um etwa 40 ms, nicht den angezeigten Ping. Die Beispiele lassen TLS-Verarbeitung, Sitzungswiederaufnahme und Paketverlust unberücksichtigt.
Die Zeile mit 490 ms und das Diagramm zeigen einen Retry bei deaktiviertem Jitter. Die zusätzliche Tabellenzeile wendet `enabled = true`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"` auf denselben Retry an; ihr Bereich stammt aus dem Modell, nicht aus Messungen. Eine frühere JavaScript-Bridge ohne Millisekundenerweiterung oder ein Vermittler, der diesen Hinweis bei deaktiviertem Jitter entfernt, behält die standardmäßige Sekundenpause bei: Der angezeigte Ping beträgt im selben Modell dann 1240 ms. Keines der Szenarien bestimmt die Ursache der ursprünglichen Beobachtung. Ohne belegte Retries andere Verzögerungen wie Warteschlangen, Frontend-Pufferung, Verbindungsaufbau oder Upstream-Arbeit untersuchen. Timeouts dürfen nicht als Wartepausen eingesetzt und überlappende Phasen nicht ungeprüft in dieses sequenzielle Modell übertragen werden.
## HTTP, WebSocket und weitere Verzögerungen
Überlastungsantworten von Telemt enthalten `Retry-After: 1` und `X-Telemt-Retry-After-Ms: 250`. Nur Status `503` mit diesem exakten Header-Paar legt in der generierten Bridge eine **Basispause von 250 ms** fest. Bei `web.retry_jitter.enabled = false` beträgt die Pause 250 ms; bei aktiviertem Jitter kommt der konfigurierte zufällige Zuschlag hinzu. Andere Antworten verwenden die unten beschriebene Standardbehandlung von `Retry-After` oder den Backoff ohne Serverhinweis. Frühere JavaScript-Bridge-Seiten ohne Unterstützung des Millisekundenhinweises verwenden bis zum Laden einer neuen Seite die Sekundenpause; ein Update der Telegram-App ist dafür nicht erforderlich. Versuchslimits, absolute Budgets, Abbruchverhalten und Zulassungsgrenzen bleiben unverändert.
| Faktor | Auswirkung und Beleg |
| --- | --- |
| HTTP-Uplink | Die generierte Bridge bietet `X-Telemt-Up-Window: 4` an; der Server akzeptiert 1–4, ohne Aushandlung gilt 1. Fenster 1 wartet vor dem nächsten Upload auf ein ACK; Fenster 4 erlaubt begrenzte Parallelität. Nicht jeder HTTP-Pfad arbeitet nach Stop-and-Wait. Siehe [Aushandlung][runtime] und [Sitzungshandler][session]. |
| Erster Downlink | `/down` kann gleichzeitig mit `/up` starten; es gibt keinen obligatorischen zusätzlichen sequenziellen ACK-RTT. Die erste Zustellung an den nativen Client wartet auf das erste gültige Upload-ACK/Commit. Frames werden schrittweise zugestellt. Siehe [Conveyor][conveyor] und [Downlink][downlink]. |
| Long Polling | `long_poll_secs=25` begrenzt das Warten ohne Daten. Verfügbare Daten wecken den Poll sofort. Lücken zwischen Polls oder belegte Browser-Verbindungsslots können die Zustellung dennoch verzögern. Siehe [serverseitiges Polling][poll]. |
| Gemeinsame Transportressourcen | `https` teilt eine Upload-Warteschlange; `https-lanes` trennt logische Lanes. WebSocket überträgt nach dem Upgrade Frames ohne POST pro Frame; `websocket-lanes` trennt Sockets. Netzwerk, Flow Control und WebView-Ressourcen bleiben gemeinsam genutzt. HTTP/2 multiplexiert Streams, beseitigt aber nicht alle Verzögerungen durch Überlastung und Flow Control. Siehe [HTTP/2][http2] und [Transportanleitung](WEB_PROXY.de.md). |
| Frontend-Pufferung | Pufferung von Anfragen/Antworten oder fehlende Streaming-/Upgrade-Unterstützung kann kleine Nachrichten verzögern. Mit dem bestehenden NGINX-Beispiel vergleichen; Routing, Authentifizierung und Limits beibehalten. |
| Client-Scheduling | Java-Accept/Read-Threads, UI-Dispatch, WebView/JavaScript und die Rückgabe an nativen Code erzeugen Warteschlangen. CPU-Last, mobiles Energiesparen und Netzwechsel können dies verstärken. |
| Weiterleitung und Netzwerk | Verbleibende Stream-Zulassung, innerer MTProxy-Handshake, Direct-Verbindungsaufbau oder ME-Registrierung, Telegram-Antwort, Paketverluste/Wiederholungen und Warteschlangen können beitragen. Eine bereite WEB-Sitzung belegt keinen bereiten Upstream. |
| Fehler und Wiederholungen | Wiederholbare Statuscodes sind 408/429/502/503/504. Bei deaktiviertem Jitter ergibt der exakte Telemt-Hinweis eine Pause von 250 ms; ohne positiven Serverhinweis beginnt der Backoff bei 250 ms und verdoppelt sich bis zu einer Basis von 2000 ms zuzüglich des bisherigen Jitters unter 25%. Bei aktiviertem Jitter ist die Pause der positive Serverhinweis oder diese Basis **plus** der konfigurierte Zufallswert, der den bisherigen Jitter ersetzt. Budgets und aufrufspezifische Versuchslimits gelten weiterhin. Die Richtlinie der geladenen Bridge und tatsächliche Retry-Zeitpunkte festhalten. Siehe [Antworten][response] und [Retry-Logik][request]. |
Fristen sind keine obligatorischen Pausen. Die Standardwerte von 10 s für Bridge-Anfragen, 90 s Retry-Budget, 30 s bis zum ersten Stream-Byte und 10 s für den Stream-Handshake dürfen nicht zum normalen Ping addiert werden. `carrier_probe_coalesce_ms=0` deaktiviert diese optionale Verzögerung zur Zusammenfassung von Probes. Carrier-Fallback-Fristen betreffen fehlgeschlagene Versuche, nicht jeden erfolgreichen Austausch. Siehe [Standardwerte](../../src/config/types/web/defaults.rs).
Vereinfacht umfasst eine WEB-Messung **verbleibenden Aufbau + Transport-/Netzwerkübertragung + Warteschlangen + Telegram-Antwort + Retry-Pausen innerhalb des Timers**. Die Phasen überlappen; alle Timeouts zu addieren oder jedem HTTP-Endpunkt einen eigenen RTT zuzuordnen wäre falsch. Bereite Carrier können Aufbaukosten reduzieren, die Android-Proxyprüfung verwendet jedoch weiterhin einen separaten Transport. WebSocket ist nicht grundsätzlich schneller; nur vom tatsächlichen Client unterstützte Carrier verwenden. Die aktuelle iOS-Kompatibilitätskonfiguration nutzt `https`.
## Diagnose unter Beibehaltung der Schutzmechanismen
1. Client/Build, Android-WebView-Version, die `web.retry_jitter`-Richtlinie der geladenen Bridge, Carrier, öffentlichen Endpunkt, IP-Familie, Netzwerk, Direct/ME-Route, DC und Last erfassen. Unter gleichen Bedingungen vergleichen. Den zwei Minuten lang gespeicherten Android-Wert beachten: Wiederholtes Ablesen liefert keine neuen Messungen.
2. Drei Experimente unterscheiden: neue Prüfung in der Proxyliste; neuer logischer Stream auf einem bereiten Carrier; Anwendungs-RPC in einer bestehenden MTProto-Sitzung. Die letzten beiden benötigen geeignete Client-Instrumentierung und werden nicht automatisch von der Proxyliste bereitgestellt. Einzelwerte und Verteilungen erfassen, statt einen kalten mit einem warmen Einzelwert zu vergleichen.
3. Das authentifizierte `GET /web-status` und vorhandene `[web.debug]`-Einstellungen mit `capture_timings=true`, `capture_frames=true`, `body_capture="metadata"` verwenden. Begrenzte Erfassung gemäß [WEB-Diagnoseanleitung](WEB_PROXY.de.md) aktivieren und danach den vorherigen Zustand wiederherstellen. Payloads, Capabilities und Secrets sind hierfür unnötig.
4. Sitzungserstellung, Carrier-Auswahl/Commit/Fehler, `stream_admitted`, `stream_first_byte`, `handshake_succeeded`, OPEN/DATA, HTTP-Statuscodes und Retries korrelieren. `relay_started` liegt vor der Upstream-Arbeit und ist kein DC-Bereitschaftszeitpunkt. `GET /v1/runtime/web/status` und `GET /v1/runtime/web/sessions` liefern Kapazitäts- und Warteschlangenkontext, keinen Ende-zu-Ende-RTT.
5. `request_body_us`, `response_ready_us` und `response_body_us` sind Zeitabstände seit Eintritt in den Service. Sie sind weder unabhängige, zu addierende Dauern noch Zeitpunkte eines Netzwerk-Flushs oder TCP-ACKs der Gegenstelle. Nur vorhandene und sinnvoll vergleichbare Werte gegenüberstellen. Siehe [Timing-Definitionen][timings].
6. Verbindungs-/TLS-/Anfragezeiten des Frontends und Client-Zeitstempel ergänzen, um Verzögerungen außerhalb von Telemt zu lokalisieren. Wiederverwendung von Verbindungen, ausgehandelte HTTP-Version, Pufferung und Upgrade prüfen. Sideband-Ereignisse zum Bridge-Start/HELLO ergeben keine vollständige Client-Zeitleiste. Für Vergleiche zwischen Hosts Uhren synchronisieren oder lokale Dauern verwenden.
7. Bei ME den vorhandenen Trace `ME RTT sample` (`rtt_ms`, `ema_ms`, `base_ms`, `degraded`) vergleichen. Der Zeitstempel liegt vor Writer-Warteschlange/Flush; die EMA besteht aus 80% vorherigem Wert und 20% neuer Messung. Keepalive-Scheduling vor diesem Zeitstempel ist ausgeschlossen. Ein niedriger ME-Wert bei hohem Android-Ping weist auf Arbeit außerhalb dieses ME-Messbereichs hin, benennt aber keine konkrete Ursache. Siehe [Writer][writer] und [Reader][reader].
Interpretationsbeispiele: Schnelle Telemt-Verarbeitung bei später Client-Zustellung lenkt die Untersuchung auf Frontend, Netzwerk und Client-Warteschlangen; `503` und die Retry-Header bestimmen die Basispause, während die geladene Jitter-Richtlinie und Client-Zeitstempel die tatsächliche Pause belegen; lange Upstream-Arbeit erfordert eine Direct/ME-Untersuchung. Keines dieser Muster beweist allein, dass TLS-Kryptografie 1000–1500 ms verursacht. Kompatibilität, Zertifikatsprüfung, Zulassungslimits und Backpressure während der Untersuchung beibehalten.
## Grenzen der Belege
Dieses Dokument beschreibt geprüften Quellcode, keinen Ende-zu-Ende-Gerätebenchmark. Android-/iOS-Links verweisen auf die genannten festen Revisionen; Telemt-Links auf die begleitende Implementierung im Arbeitsverzeichnis einschließlich lokaler WEB-Änderungen. Daraus folgt keine Gleichheit aller veröffentlichten Pakete. Die Dokumentation ändert weder Client noch Server, Timeouts oder Sicherheitseinstellungen.
[android-entry]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/tgnet/ConnectionsManager.java#L752-L765
[android-tester]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyConnectionTester.java#L111-L172
[android-transport]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyTransport.java
[android-native]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionsManager.cpp
[android-socket]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionSocket.cpp
[android-key]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/Datacenter.cpp#L1312-L1323
[android-ui]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/ui/ProxyListActivity.java#L671-L687
[ios-filter]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/TelegramCore/Sources/Network/ProxyServersStatuses.swift#L54-L65
[ios-probe]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/MtProtoKit/Sources/MTProxyConnectivity.m#L57-L99
[ios-ui]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/SettingsUI/Sources/Data%20and%20Storage/ProxyListSettingsController.swift#L273-L310
[tls13]: https://datatracker.ietf.org/doc/html/rfc8446#section-2
[tls12]: https://datatracker.ietf.org/doc/html/rfc5246#section-7.3
[http1]: https://www.rfc-editor.org/rfc/rfc9112#section-9.3
[http2]: https://datatracker.ietf.org/doc/html/rfc9113#section-5
[session]: ../../src/web/http/session.rs
[backend]: ../../src/web/session/backend.rs
[runtime]: ../../src/web/bridge/runtime.js
[conveyor]: ../../src/web/bridge/conveyor.js
[downlink]: ../../src/web/bridge/downlink.js
[poll]: ../../src/web/session/downlink.rs
[request]: ../../src/web/bridge/request.js
[response]: ../../src/web/http/response.rs
[timings]: ../../src/web/trace/types.rs
[writer]: ../../src/transport/middle_proxy/pool_writer.rs
[reader]: ../../src/transport/middle_proxy/reader.rs
+244
View File
@@ -0,0 +1,244 @@
# Proxy-list ping: RPC Ping/Pong and WEB latency
[English](PROXY_PING.en.md) | [Русский](PROXY_PING.ru.md) | [Deutsch](PROXY_PING.de.md)
The reported **1000–1500 ms for WEB versus 50–150 ms for Classic/DD/EE** can compare different measurement boundaries. Android's WEB checker sees a local socket become ready before the remote WEB stream is necessarily ready. Remaining stream setup and carrier work can therefore enter the displayed result. The code does not impose a fixed one-second penalty on every successful WEB ping.
These numbers are operator observations, not measurements reproduced for this document. Source inspection establishes mechanisms; it does not identify the cause of an individual deployment's delay.
## Clients and measurements
Checked on **2026-10-31** against DrKLO Android `3de37c8cfb039452be7398909461da6288babf15` and Telegram iOS `279f89e4d4f63f17227bf877dc3d8990f22ecd58`. Installed releases and forks may differ.
| Measurement | What it covers | WEB behavior |
| --- | --- | --- |
| Android proxy-list ping | Encrypted MTProto `TL_ping` / `TL_pong` through the checked proxy | Separate WEB test transport, then native checker through a local socket. |
| iOS proxy-list ping | `req_pq` / `resPQ` connectivity probe; timing starts at `connectionOpened` | `.web` entries are excluded from ping contexts; an inactive WEB row is shown as not tested. |
| ICMP ping | IP echo to the selected host, subject to ICMP routing and policy | Does not measure the application relay or Telegram DC. |
| WEB transport `FRAME_PING` / `FRAME_PONG` or WebSocket keepalive | Carrier/session liveness | Not Android's displayed MTProto result. |
| Telemt ME RPC Ping/Pong | One Telemt-to-ME writer path, including local queue/flush scheduling | Excludes the client, WebView and public TLS frontend. |
Sources: [Android entry point][android-entry], [native requests][android-native], [iOS filter][ios-filter], [iOS probe][ios-probe], [iOS UI][ios-ui]. The current iOS WEB row therefore cannot be interpreted using Android's WEB ping formula.
Android displays the last checker result, without a median or EMA, and skips a new check when the previous check is less than two minutes old. A connected row can still show that stored result: it is not continuous RTT monitoring of the active session. The WEB checker creates its own transport rather than directly reusing the active app transport. See [proxy-list refresh][android-ui] and [WEB transport][android-transport].
## Where Android starts and stops the clock
For Classic/DD/EE, the native connection-ready callback follows remote TCP and the applicable proxy transport setup, including EE FakeTLS. Request processing refreshes the timestamp before sending/re-sending the probe. The normal successful sample therefore excludes that initial transport establishment. It includes subsequent relay work and the response path. See [socket readiness][android-socket] and [request processing][android-native].
For WEB, bridge startup, its initial HTTPS load, HELLO/WELCOME and an additional **500 ms** wait precede `native_checkProxy`; that wait is not added to its reported ping. WELCOME confirms a WEB session, not a ready Telegram DC stream. The native checker then connects to `127.0.0.1`, whose readiness does not wait for remote OPEN completion. Java acceptance/OPEN and the native timestamp update can race. Only work still pending inside the measured interval contributes. See [test sequencing][android-tester], [local socket and WELCOME][android-transport], [server session creation][session] and [stream backend][backend].
The diagrams show the normal successful path, not a guarantee of event ordering across threads:
```mermaid
sequenceDiagram
participant C as Android native checker
participant P as Classic/DD/EE proxy
participant D as Telegram DC
C->>P: DNS / TCP / proxy transport setup
Note over C,P: Remote transport ready, measurement timestamp refreshed
C->>P: Encrypted TL_ping
P->>D: Relay
D->>P: TL_pong
P->>C: Relay
Note over C: Display elapsed time from latest request timestamp
```
```mermaid
sequenceDiagram
participant C as Android native checker
participant B as Local socket / Java / WebView
participant T as TLS frontend / Telemt
participant D as Telegram DC
B->>T: Bridge load, HELLO / session creation
T->>B: WELCOME
Note over B: Wait 500 ms before native_checkProxy
C->>B: Connect to 127.0.0.1
Note over C,B: Local socket ready, measurement timestamp refreshed
Note over B,T: OPEN may race with the native timestamp update
C->>B: MTProxy header and encrypted TL_ping
B->>T: OPEN / DATA over selected carrier
T->>D: Stream setup and relay via Direct / ME
D->>T: TL_pong
T->>B: Downlink DATA
B->>C: Local socket delivery
Note over C: Display elapsed time from latest request timestamp
```
`TL_pong` completes the sample using the latest request timestamp. Force processing or resend can reset that timestamp, so the displayed value is not necessarily the full wall time of a failed/retried attempt. The checker uses an [existing DC authorization key][android-key]; do not count a complete new MTProto authorization-key exchange as an obligatory part of every check.
## What TLS contributes
The public TLS connection terminates at NGINX, HAProxy or Caddy. Telemt receives plain HTTP/1.1 on its private listener. Frontend HTTP/2 and the internal HTTP/1.1 hop are separate connections; their protocol versions need not match. EE FakeTLS is an MTProxy camouflage transport, not the browser's certificate-authenticated HTTPS connection. WEB carries plain/DD MTProxy streams; EE is not a WEB secret format. See the [WEB deployment guide](WEB_PROXY.en.md).
For a new connection without loss, the usual TCP handshake costs about one client-to-frontend RTT. A full TLS 1.3 handshake normally adds one RTT; a full TLS 1.2 handshake normally adds two. These are protocol-flight estimates excluding DNS, processing, certificate validation, scheduling and retransmissions, not a measured WEB latency budget. Resumption can change the exchange. See [TLS 1.3][tls13] and [TLS 1.2][tls12].
Initial bridge TLS normally occurs before Android's measurement. Additional HTTP connections or a WebSocket Upgrade can still occur inside it; connection reuse must be established from a trace. Persistent HTTPS and an established WebSocket do not perform a new TLS handshake for every ping. `cache: 'no-store'` controls caching, not TCP/TLS reuse. Neither disabling certificate checks nor enabling TLS 0-RTT is required to explain or diagnose this behavior. See [HTTP persistence][http1] and [bridge requests][request].
## Where WEB adds work compared with native MTProxy
The comparison below separates work before the Android timer from work that can enter the displayed sample. The baseline is Classic/DD over TCP. For EE, FakeTLS belongs to native transport setup. Box sizes and arrow lengths do not represent elapsed time.
```mermaid
flowchart LR
subgraph N["Native MTProxy: Classic/DD"]
direction TB
N0["Before measurement<br/>DNS + TCP + native transport setup"] --> N1["Remote transport ready<br/>Request timestamp refreshed"]
N1 --> N2["MTProxy request and response<br/>Relay setup still pending, network, queues, Telegram DC"] --> N3["TL_pong received<br/>Displayed sample ends"]
end
subgraph W["WEB: MTProxy inside HTTPS or WebSocket"]
direction TB
W0["Before measurement<br/>DNS + TCP + real TLS<br/>HTTP bridge + HELLO/WELCOME + 500 ms"] --> W1["Local socket ready<br/>Request timestamp refreshed"]
W1 --> W2["Extra client work<br/>Java / WebView / JavaScript queues<br/>Remote OPEN may still be pending"] --> W3["Uplink carrier work<br/>HTTP upload or WebSocket frame<br/>Frontend handling and possible buffering"]
W3 --> W4["Inner MTProxy request and response<br/>Relay setup still pending, network, queues, Telegram DC"] --> W5["Downlink carrier work<br/>HTTP poll or WebSocket frame<br/>ACK/commit gate, native delivery queues"]
W5 --> W6["TL_pong received<br/>Displayed sample ends"]
X["Only if another connection is needed<br/>TCP + TLS, possible WebSocket Upgrade<br/>No handshake per ping on a reused connection"] -.-> W3
X -.-> W5
R["Only on errors or overload<br/>Retries and backoff<br/>Telemt base 250 ms<br/>Plus optional jitter"] -.-> W3
R -.-> W5
end
classDef preparation fill:#eeeeee,stroke:#666666,color:#111111
classDef boundary fill:#dbeafe,stroke:#2563eb,color:#111111
classDef shared fill:#dcfce7,stroke:#15803d,color:#111111
classDef extra fill:#ffedd5,stroke:#c2410c,color:#111111
class N0,W0 preparation
class N1,N3,W1,W6 boundary
class N2,W4 shared
class W2,W3,W5,X,R extra
```
**Gray:** work before the displayed sample. **Blue:** timer boundaries. **Green:** MTProxy relay/DC work present in both paths, whose actual cost can differ. **Orange:** WEB carrier and client work inside the sample. **Dashed arrows:** conditional connection setup or retries, not obligatory stages.
On a reused carrier, the conditional TCP/TLS/Upgrade cost disappears, while framing, HTTP or WebSocket handling and client queues remain. HTTP can overlap uplink and downlink; it does not necessarily add a whole RTT for every request. Polling wakes on data, and frontend buffering is deployment-dependent. Any delay from loss, congestion or upstream setup may also affect native MTProxy.
Thus the visible difference can include remaining WEB stream setup, additional connection setup, carrier handling and scheduling, plus retries when observed. It is not a fixed TLS surcharge or an arithmetic sum of all boxes. The initial real TLS handshake is shown outside the Android timer deliberately: it affects total time to check, but normally not the displayed sample. The separate checker transport also means that an active app connection does not guarantee a warm proxy-list check.
### Configurable HTTP retry jitter
`[web.retry_jitter]` adds an independently sampled delay to every existing bridge HTTP retry (network errors and 408/429/502/503/504). It requires no Telegram application changes. Without this section, or with `enabled = false`, the existing 250 ms Telemt hint and legacy backoff/jitter remain unchanged.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
The range describes an **addition**, not the total pause: the Telemt 250 ms hint plus this example gives 250–500 ms. Without a positive server hint, the base remains 250, 500, 1000, then 2000 ms. Enabled jitter replaces the legacy 25% jitter. A valid standard `Retry-After` is not shortened to the legacy 30-second cap in enabled mode. If the pause exceeds the remaining operation budget, the budget expires without an early retry.
Defaults are `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, and no `peak_ms`. Require `0 <= min_ms <= max_ms <= 30000`. `uniform` samples integer milliseconds; `triangular` samples a triangular density and rounds to milliseconds. Its optional peak defaults to the midpoint and must be within the interval. An explicit peak with `uniform`, unknown keys/distributions and invalid ranges are rejected even when disabled. Equal bounds produce a constant addition.
The policy is frozen into each bridge page. Config/API reload affects newly loaded pages; existing pages retain it through recovery. Recovery JSON and HTTP compatibility headers remain unchanged. Sampling prefers `crypto.getRandomValues`, with `Math.random` as a compatibility fallback. Successful traffic is not delayed. Attempt limits, absolute budgets, cancellation and payload/sequence replay remain intact; longer pauses can exhaust a startup/recovery budget sooner in terms of completed attempts.
This reduces fixed retry timing, without guaranteeing resistance to DPI. Browser scheduling, network queues and other protocol timing remain observable. Existing timeline examples describe the default disabled mode. To evaluate enabled mode, add a sampled delay to each retry pause, not to the initial connection or every data frame.
The policy changes existing retry waits only. Stricter per-call limits still apply: ordinary `/down` polls and committed `/up` calls use one attempt; their recovery replay permits two. This option does not create new retry loops. It neither extends `web.timeouts.bridge_retry_secs` nor any startup/recovery deadline.
## Illustrative timeline with approximate durations
**An explanatory model, not a benchmark or an expected latency range.** The numbers below are chosen to show how the stages combine. They do not reconstruct the reported 1000–1500 ms. Assume cached DNS, a 40 ms client-to-frontend RTT, a new TCP connection, a full TLS 1.3 handshake where required, an existing DC authorization key, and no loss or native request-timestamp reset. The baseline is Classic/DD. EE adds its own FakeTLS setup before the native timer.
For this example, native preparation takes 40 ms for TCP. WEB preparation takes 750 ms: 50 ms client/bridge startup + 40 ms TCP + 40 ms TLS + 120 ms HTTP bridge/session exchange + the Android checker's 500 ms wait. These are chosen serial durations for this example; real stages can overlap. The initial TLS and the 500 ms wait are outside the displayed ping.
The diagram starts at each checker's **latest request timestamp**, not at app launch or the first connection attempt. The horizontal axis shows seconds with millisecond precision (0.200 = 200 ms). Bar widths are proportional to duration. Each section is a separate scenario; its stages are successive, not parallel. Initial preparation remains outside the diagram.
```mermaid
%%{init: {"theme": "neutral", "gantt": {"leftPadding": 200, "rightPadding": 100, "barHeight": 24, "barGap": 8, "fontSize": 14, "sectionFontSize": 14}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 100millisecond
todayMarker off
title Displayed ping - jitter off
accTitle: Displayed ping - jitter off
accDescr: Seconds from the request timestamp, labels show milliseconds
section Classic/DD - 100 ms
R 100 ms :n_r, 0, 100ms
section WEB without errors - 200 ms
U 40 ms :w_u, 0, 40ms
R 100 ms :w_r, after w_u, 100ms
D 60 ms :w_d, after w_r, 60ms
section WEB with retry - 490 ms
F 40 ms :crit, f_u, 0, 40ms
Wait 250 ms :crit, retry_wait, after f_u, 250ms
U 40 ms :retry_u, after retry_wait, 40ms
R 100 ms :retry_r, after retry_u, 100ms
D 60 ms :retry_d, after retry_r, 60ms
```
Stage durations: `R` = 100 ms of baseline MTProxy relay/network/DC request-response work. `U` = 40 ms of additional WEB uplink/OPEN/client work. `D` = 60 ms of additional WEB downlink/delivery work. `F` = 40 ms spent on one failed upload attempt. `Wait` = the 250 ms wait requested by `X-Telemt-Retry-After-Ms: 250`. `U` and `D` are illustrative incremental costs beyond `R`, not a second count of the full network round trip. ACK/commit gating is included only insofar as it actually delays delivery.
| Scenario | Before ping timer | Displayed ping | Total check time in this model |
| --- | --- | --- | --- |
| Classic/DD | 40 ms | 100 ms | 140 ms |
| WEB, no errors | 750 ms | 40 + 100 + 60 = 200 ms | 950 ms |
| WEB, one failed upload and one retry wait | 750 ms | 40 + 250 + 40 + 100 + 60 = 490 ms | 1240 ms |
| WEB, same retry with uniform jitter 0–250 ms | 750 ms | 490–740 ms | 1240–1490 ms |
A further TCP + TLS 1.3 connection opened **inside** the measured interval could add approximately 80 ms here, if fully sequential: the 200 ms sample would become 280 ms. With connection reuse, that extra handshake cost is zero. Switching the initial full handshake from TLS 1.3 to TLS 1.2 adds about 40 ms to preparation under these assumptions, not to the displayed ping. These examples omit TLS processing, resumption and packet loss.
The 490 ms row and diagram illustrate one retry with jitter disabled. The extra table row applies `enabled = true`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"` to that same retry; its range comes from this model, not measurements. An earlier JavaScript bridge without the millisecond extension, or an intermediary removing that hint when jitter is disabled, retains the standard one-second fallback: the same model then gives a displayed ping of 1240 ms. Neither scenario identifies the cause of the original observation. Without trace evidence of retries, locate other delays such as queues, frontend buffering, connection setup or upstream work. Do not insert timeouts as waiting periods or force overlapping stages into this serial model.
## HTTP, WebSocket and other delay sources
Telemt overload responses carry `Retry-After: 1` and `X-Telemt-Retry-After-Ms: 250`. Only status `503` with that exact header pair selects a **250 ms base delay** in the generated bridge. With `web.retry_jitter.enabled = false`, the pause is 250 ms; when enabled, the configured random addition applies. Other responses use the standard `Retry-After` handling or fallback backoff described below. Earlier JavaScript bridge pages without support for the millisecond hint use the one-second fallback until replaced by a newly loaded page; this does not require updating the Telegram application. Attempt limits, absolute budgets, cancellation and admission limits remain unchanged.
| Factor | Effect and evidence |
| --- | --- |
| HTTP uplink | The generated bridge offers `X-Telemt-Up-Window: 4`; the server accepts 1–4, with legacy fallback 1. Window 1 waits for an ACK before the next upload; window 4 permits bounded concurrency. Not all HTTP traffic is stop-and-wait. See [negotiation][runtime] and [session handler][session]. |
| First downlink | `/down` can start alongside `/up`; there is no unconditional extra sequential ACK RTT. Initial native delivery is gated by the first valid upload ACK/commit. Frames are delivered incrementally. See [conveyor][conveyor] and [downlink][downlink]. |
| Long polling | `long_poll_secs=25` bounds an empty wait. Available data wakes the poll immediately. Gaps between polls or occupied browser connection slots can still delay delivery. See [server polling][poll]. |
| Carrier sharing | `https` shares its upload queue; `https-lanes` separates logical lanes. WebSocket carries frames after Upgrade, without a POST per frame; `websocket-lanes` separates sockets. Shared network, flow-control and WebView resources remain. HTTP/2 multiplexes streams but does not remove all congestion or flow-control stalls. See [HTTP/2][http2] and the [carrier guide](WEB_PROXY.en.md). |
| Frontend buffering | Request/response buffering or missing streaming/Upgrade support can delay small messages. Compare with the existing NGINX example; preserve routing, authentication and limits. |
| Client scheduling | Java accept/read threads, UI dispatch, WebView/JavaScript and delivery back to native code add queues. CPU pressure, mobile power management and network transitions can amplify them. |
| Relay and network | Remaining stream admission, inner MTProxy handshake, Direct connection or ME registration, Telegram response, loss/retransmission and queue pressure can contribute. A ready WEB session does not prove a ready upstream. |
| Errors and retries | Retryable statuses are 408/429/502/503/504. With jitter disabled, the exact Telemt overload hint gives a 250 ms pause; without a positive server hint, backoff starts at 250 ms and doubles to a 2000 ms base plus legacy jitter below 25%. With jitter enabled, the pause is the positive server hint or that base **plus** the configured sample, replacing legacy jitter. Budgets and per-call attempt limits still apply. Record the loaded bridge policy and actual retry timestamps. See [responses][response] and [retry logic][request]. |
Deadlines are not mandatory sleeps. The default 10 s bridge-request deadline, 90 s retry budget, 30 s stream-first-byte deadline and 10 s stream-handshake deadline must not be summed into normal ping. `carrier_probe_coalesce_ms=0` disables that optional coalescing delay. Carrier fallback deadlines affect failed attempts, not every successful exchange. See [timeout defaults](../../src/config/types/web/defaults.rs).
A conceptual WEB sample is **remaining setup + carrier/network transit + queues + Telegram response + any retry delays still inside the timer**. These stages overlap; adding all configured timeouts or counting one RTT per HTTP endpoint is incorrect. Warm carriers can reduce setup costs, but the Android proxy checker still uses a separate transport. WebSocket is not universally faster; use only carriers supported by the actual client. The current iOS compatibility configuration uses `https`.
## Diagnosis without changing protections
1. Record client/build, Android WebView version, the loaded bridge’s `web.retry_jitter` policy, carrier, public endpoint, IP family, network, Direct/ME route, DC and load. Match these conditions for comparison. Respect Android's two-minute cached result; do not treat repeated UI reads as new samples.
2. Separate three experiments: a new proxy-list check; a new logical stream on a ready carrier; application RPC on an established MTProto session. The latter two require suitable client instrumentation and are not automatically provided by the proxy-list display. Record individual samples and distributions rather than comparing one cold sample with one warm sample.
3. Use the authenticated `GET /web-status` and existing `[web.debug]` controls with `capture_timings=true`, `capture_frames=true`, `body_capture="metadata"`. Enable bounded capture as described in the [WEB diagnostics guide](WEB_PROXY.en.md), then restore its prior state. Payloads, capabilities and secrets are unnecessary for this investigation.
4. Correlate session creation, carrier selection/commit/failure, `stream_admitted`, `stream_first_byte`, `handshake_succeeded`, OPEN/DATA, HTTP status and retries. `relay_started` precedes upstream work and is not a DC-ready timestamp. Use `GET /v1/runtime/web/status` and `GET /v1/runtime/web/sessions` for capacity and queue-pressure context, not end-to-end RTT.
5. Interpret `request_body_us`, `response_ready_us` and `response_body_us` as offsets from service entry. They are not independent durations to add, nor timestamps of wire flush or peer TCP ACK. Compare offsets only where recorded and meaningful. See [trace timing definitions][timings].
6. Add frontend connection/TLS/request timing and client timestamps to locate delays outside Telemt. Establish connection reuse, negotiated HTTP version, buffering and Upgrade behavior. Bridge startup/HELLO sideband events do not provide a complete client timeline. Synchronize clocks for cross-host comparisons or use durations on each host.
7. If ME is involved, compare the existing `ME RTT sample` trace (`rtt_ms`, `ema_ms`, `base_ms`, `degraded`). The timestamp precedes the writer queue/flush, and the EMA uses 80% previous value plus 20% new sample. Keepalive scheduling before that timestamp is excluded. A low ME value with high Android ping points to work outside that measured ME segment, without identifying one particular cause. See [writer][writer] and [reader][reader].
Evidence patterns: fast Telemt handling with late client delivery suggests investigating frontend/network/client queues; `503` and its retry headers identify the base delay, while the loaded jitter policy and client timestamps establish the actual pause; long upstream work calls for Direct/ME investigation. None alone proves that TLS cryptography causes 1000–1500 ms. Keep compatibility, certificate verification, admission limits and backpressure intact while collecting evidence.
## Evidence scope
This document describes inspected source behavior, not an end-to-end device benchmark. Android/iOS links pin the revisions above; Telemt links refer to the accompanying working-tree implementation, including local WEB changes, rather than claiming every published package has identical behavior. No client, server, timeout or security setting is changed by this documentation.
[android-entry]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/tgnet/ConnectionsManager.java#L752-L765
[android-tester]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyConnectionTester.java#L111-L172
[android-transport]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyTransport.java
[android-native]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionsManager.cpp
[android-socket]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionSocket.cpp
[android-key]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/Datacenter.cpp#L1312-L1323
[android-ui]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/ui/ProxyListActivity.java#L671-L687
[ios-filter]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/TelegramCore/Sources/Network/ProxyServersStatuses.swift#L54-L65
[ios-probe]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/MtProtoKit/Sources/MTProxyConnectivity.m#L57-L99
[ios-ui]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/SettingsUI/Sources/Data%20and%20Storage/ProxyListSettingsController.swift#L273-L310
[tls13]: https://datatracker.ietf.org/doc/html/rfc8446#section-2
[tls12]: https://datatracker.ietf.org/doc/html/rfc5246#section-7.3
[http1]: https://www.rfc-editor.org/rfc/rfc9112#section-9.3
[http2]: https://datatracker.ietf.org/doc/html/rfc9113#section-5
[session]: ../../src/web/http/session.rs
[backend]: ../../src/web/session/backend.rs
[runtime]: ../../src/web/bridge/runtime.js
[conveyor]: ../../src/web/bridge/conveyor.js
[downlink]: ../../src/web/bridge/downlink.js
[poll]: ../../src/web/session/downlink.rs
[request]: ../../src/web/bridge/request.js
[response]: ../../src/web/http/response.rs
[timings]: ../../src/web/trace/types.rs
[writer]: ../../src/transport/middle_proxy/pool_writer.rs
[reader]: ../../src/transport/middle_proxy/reader.rs
+244
View File
@@ -0,0 +1,244 @@
# Ping в списке прокси: RPC Ping/Pong и задержки WEB
[English](PROXY_PING.en.md) | [Русский](PROXY_PING.ru.md) | [Deutsch](PROXY_PING.de.md)
Наблюдаемые **1000–1500 мс для WEB против 50–150 мс для Classic/DD/EE** могут относиться к измерениям с разными границами. В Android локальный сокет WEB-проверки становится готов раньше, чем обязательно готов удалённый WEB-поток. Оставшееся открытие потока и работа транспорта могут попасть в показанный результат. Код не задаёт фиксированный секундный штраф для каждого успешного WEB-ping.
Эти числа предоставлены оператором и не воспроизведены при подготовке документа. Анализ исходников подтверждает механизмы, но не устанавливает причину задержки в конкретном развёртывании.
## Клиенты и измерения
Проверено **2026-10-31** по DrKLO Android `3de37c8cfb039452be7398909461da6288babf15` и Telegram iOS `279f89e4d4f63f17227bf877dc3d8990f22ecd58`. Установленные релизы и форки могут отличаться.
| Измерение | Что охватывает | Поведение WEB |
| --- | --- | --- |
| Ping в списке прокси Android | Зашифрованный MTProto `TL_ping` / `TL_pong` через проверяемый прокси | Отдельный WEB-транспорт проверки, затем нативная проверка через локальный сокет. |
| Ping в списке прокси iOS | Проверка связности `req_pq` / `resPQ`; отсчёт от `connectionOpened` | Записи `.web` исключены из проверки; неактивный WEB-прокси отображается как непроверенный. |
| ICMP ping | IP echo до выбранного хоста с учётом маршрутизации и политики ICMP | Не измеряет прикладную пересылку и Telegram DC. |
| WEB transport `FRAME_PING` / `FRAME_PONG` или WebSocket keepalive | Жизнеспособность транспорта/сессии | Это не отображаемый Android результат MTProto. |
| Telemt ME RPC Ping/Pong | Один путь от Telemt до ME, включая локальную очередь writer и планирование flush | Не включает клиент, WebView и публичный TLS-фронтенд. |
Источники: [точка входа Android][android-entry], [нативные запросы][android-native], [фильтр iOS][ios-filter], [проверка iOS][ios-probe], [интерфейс iOS][ios-ui]. Поэтому формулу WEB-ping Android нельзя переносить на WEB-строку текущего iOS.
Android показывает последний результат проверки без медианы или EMA и пропускает новую проверку, если предыдущая была менее двух минут назад. У подключённого прокси тоже может отображаться сохранённый результат: это не непрерывное измерение RTT активной сессии. WEB-проверка создаёт собственный транспорт, а не использует напрямую активный транспорт приложения. См. [обновление списка][android-ui] и [WEB-транспорт][android-transport].
## Где Android запускает и останавливает таймер
Для Classic/DD/EE нативный callback готовности приходит после удалённого TCP-соединения и необходимой подготовки транспорта прокси, включая EE FakeTLS. Обработка запроса обновляет временную метку перед отправкой или повторной отправкой проверки. Поэтому обычный успешный замер исключает это начальное установление транспорта, но включает последующую пересылку и обратный путь. См. [готовность сокета][android-socket] и [обработку запросов][android-native].
Для WEB запуск моста, начальная HTTPS-загрузка, HELLO/WELCOME и дополнительные **500 мс** ожидания предшествуют `native_checkProxy`; эти 500 мс не прибавляются к показанному ping. WELCOME подтверждает WEB-сессию, а не готовность потока до Telegram DC. Затем нативная проверка подключается к `127.0.0.1`: готовность этого сокета не ждёт завершения удалённого OPEN. Java accept/OPEN и обновление нативной временной метки могут выполняться в разном порядке. Учитывается только работа, оставшаяся внутри интервала измерения. См. [порядок проверки][android-tester], [локальный сокет и WELCOME][android-transport], [создание серверной сессии][session] и [обработку потока][backend].
Схемы показывают обычный успешный путь, но не гарантируют порядок событий между потоками выполнения:
```mermaid
sequenceDiagram
participant C as Android native checker
participant P as Classic/DD/EE proxy
participant D as Telegram DC
C->>P: DNS / TCP / proxy transport setup
Note over C,P: Remote transport ready, measurement timestamp refreshed
C->>P: Encrypted TL_ping
P->>D: Relay
D->>P: TL_pong
P->>C: Relay
Note over C: Display elapsed time from latest request timestamp
```
```mermaid
sequenceDiagram
participant C as Android native checker
participant B as Local socket / Java / WebView
participant T as TLS frontend / Telemt
participant D as Telegram DC
B->>T: Bridge load, HELLO / session creation
T->>B: WELCOME
Note over B: Wait 500 ms before native_checkProxy
C->>B: Connect to 127.0.0.1
Note over C,B: Local socket ready, measurement timestamp refreshed
Note over B,T: OPEN may race with the native timestamp update
C->>B: MTProxy header and encrypted TL_ping
B->>T: OPEN / DATA over selected carrier
T->>D: Stream setup and relay via Direct / ME
D->>T: TL_pong
T->>B: Downlink DATA
B->>C: Local socket delivery
Note over C: Display elapsed time from latest request timestamp
```
`TL_pong` завершает замер относительно последней временной метки запроса. Принудительная обработка или повторная отправка могут обновить её: результат не обязательно равен полному времени неудачной попытки с повторами. Проверка использует [существующий ключ авторизации DC][android-key]; полный обмен для создания нового ключа MTProto не является обязательной частью каждой проверки.
## Вклад TLS
Публичное TLS-соединение завершается в NGINX, HAProxy или Caddy. Telemt получает обычный HTTP/1.1 на приватном listener. Внешний HTTP/2 и внутренний HTTP/1.1 — разные соединения; версии протокола на этих участках могут различаться. EE FakeTLS — маскирующий транспорт MTProxy, а не браузерное HTTPS-соединение с проверкой сертификата. WEB переносит plain/DD MTProxy; EE не является форматом секрета WEB. См. [руководство WEB](WEB_PROXY.ru.md).
Для нового соединения без потерь обычный TCP-handshake занимает примерно один RTT между клиентом и фронтендом. Полный TLS 1.3 handshake обычно добавляет один RTT, полный TLS 1.2 — два. Это оценка обменов протокола без DNS, обработки, проверки сертификата, планирования и повторной передачи, а не измеренный бюджет задержки WEB. Возобновление TLS-сессии может изменить обмен. См. [TLS 1.3][tls13] и [TLS 1.2][tls12].
Начальный TLS для загрузки моста обычно происходит до измерения Android. Дополнительные HTTP-соединения или WebSocket Upgrade могут оказаться внутри него; повторное использование соединений нужно подтверждать трассировкой. Постоянное HTTPS-соединение и установленный WebSocket не выполняют новый TLS-handshake для каждого ping. `cache: 'no-store'` управляет кешированием, а не повторным использованием TCP/TLS. Для объяснения и диагностики не требуется отключать проверку сертификатов или включать TLS 0-RTT. См. [постоянные HTTP-соединения][http1] и [запросы моста][request].
## Где WEB добавляет работу относительно обычного MTProxy
На схеме разделены этапы до запуска таймера Android и работа, которая может попасть в показанный результат. Базовый вариант — Classic/DD поверх TCP. Для EE FakeTLS относится к подготовке нативного транспорта. Размеры блоков и длины стрелок не обозначают длительность.
```mermaid
flowchart LR
subgraph N["Обычный MTProxy: Classic/DD"]
direction TB
N0["До измерения<br/>DNS + TCP + подготовка нативного транспорта"] --> N1["Удалённый транспорт готов<br/>Временная метка запроса обновлена"]
N1 --> N2["Запрос и ответ MTProxy<br/>Оставшаяся подготовка relay, сеть, очереди, Telegram DC"] --> N3["Получен TL_pong<br/>Отображаемый замер завершён"]
end
subgraph W["WEB: MTProxy внутри HTTPS или WebSocket"]
direction TB
W0["До измерения<br/>DNS + TCP + настоящий TLS<br/>HTTP-мост + HELLO/WELCOME + 500 мс"] --> W1["Локальный сокет готов<br/>Временная метка запроса обновлена"]
W1 --> W2["Дополнительная работа клиента<br/>Очереди Java / WebView / JavaScript<br/>Удалённый OPEN ещё может быть не завершён"] --> W3["Работа транспорта на отправке<br/>HTTP upload или кадр WebSocket<br/>Обработка фронтендом и возможная буферизация"]
W3 --> W4["Внутренний запрос и ответ MTProxy<br/>Оставшаяся подготовка relay, сеть, очереди, Telegram DC"] --> W5["Работа транспорта на приёме<br/>HTTP poll или кадр WebSocket<br/>Ожидание ACK/commit и очереди доставки в native"]
W5 --> W6["Получен TL_pong<br/>Отображаемый замер завершён"]
X["Только если нужно ещё одно соединение<br/>TCP + TLS, возможный WebSocket Upgrade<br/>При переиспользовании нет handshake на каждый ping"] -.-> W3
X -.-> W5
R["Только при ошибках или перегрузке<br/>Повторы и backoff<br/>База Telemt 250 мс<br/>Плюс опциональный jitter"] -.-> W3
R -.-> W5
end
classDef preparation fill:#eeeeee,stroke:#666666,color:#111111
classDef boundary fill:#dbeafe,stroke:#2563eb,color:#111111
classDef shared fill:#dcfce7,stroke:#15803d,color:#111111
classDef extra fill:#ffedd5,stroke:#c2410c,color:#111111
class N0,W0 preparation
class N1,N3,W1,W6 boundary
class N2,W4 shared
class W2,W3,W5,X,R extra
```
**Серый:** работа до отображаемого замера. **Синий:** границы таймера. **Зелёный:** работа MTProxy relay/DC, присутствующая в обоих путях, но не обязательно одинаковая по длительности. **Оранжевый:** работа WEB-транспорта и клиента внутри замера. **Пунктир:** условные затраты на соединения или повторы, а не обязательные этапы.
При повторном использовании транспорта условные затраты на TCP/TLS/Upgrade исчезают, но остаются формирование кадров, обработка HTTP или WebSocket и очереди клиента. HTTP может совмещать отправку и приём: каждый запрос не обязательно добавляет целый RTT. Polling пробуждается при появлении данных, а буферизация фронтенда зависит от развёртывания. Потери, перегрузка сети и подготовка upstream могут задерживать и обычный MTProxy.
Таким образом, в видимую разницу могут войти оставшееся открытие WEB-потока, дополнительные соединения, обработка транспорта, планирование и подтверждённые повторы. Это не фиксированная надбавка TLS и не арифметическая сумма блоков. Начальный настоящий TLS намеренно показан до таймера Android: он влияет на полное время проверки, но обычно не на отображаемый замер. Отдельный транспорт проверки также означает, что активное соединение приложения не гарантирует прогретую проверку в списке прокси.
### Настраиваемый jitter HTTP-повторов
`[web.retry_jitter]` добавляет независимо выбранную задержку к каждому существующему HTTP-повтору бриджа: сетевым ошибкам и ответам 408/429/502/503/504. Менять приложения Telegram не требуется. Без секции или при `enabled = false` сохраняются подсказка Telemt на 250 мс и прежний backoff/jitter.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Диапазон задаёт **добавку**, а не полную паузу: подсказка Telemt 250 мс с этим примером даёт 250–500 мс. Без положительной серверной подсказки база остаётся 250, 500, 1000, затем 2000 мс. Включённый jitter заменяет прежний jitter до 25%. Корректный стандартный `Retry-After` во включённом режиме не сокращается прежним пределом в 30 секунд. Если пауза не помещается в оставшийся бюджет операции, бюджет истекает без раннего повтора.
Значения по умолчанию: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, `peak_ms` отсутствует. Требуется `0 <= min_ms <= max_ms <= 30000`. `uniform` выбирает целые миллисекунды равномерно; `triangular` использует треугольную плотность с округлением до миллисекунд. Необязательный пик по умолчанию находится посередине и должен входить в диапазон. Явный пик для `uniform`, неизвестные ключи/распределения и неверные границы отклоняются даже при выключенной опции. Одинаковые границы дают постоянную добавку.
Политика фиксируется в каждой странице бриджа. Reload через конфиг/API влияет на новые страницы; активные сохраняют настройки при recovery. Recovery JSON и HTTP-заголовки совместимости не меняются. Используется `crypto.getRandomValues`, при недоступности — `Math.random` для совместимости. Успешный трафик не задерживается. Лимиты попыток, общий бюджет, отмена и повтор неизменных payload/sequence сохраняются; более длинные паузы могут уменьшить число попыток, помещающихся в startup/recovery-бюджет.
Это размывает фиксированные интервалы повторов, но не гарантирует защиту от DPI. Планирование браузера, сетевые очереди и другие временные характеристики протокола остаются наблюдаемыми. Существующие примеры временной шкалы описывают выключенную опцию. Для включённого режима добавляйте выбранную задержку к каждой паузе повтора, а не к первому соединению или каждому кадру данных.
Политика меняет только уже предусмотренные паузы перед повтором. Более строгие ограничения отдельных вызовов сохраняются: обычный poll `/down` и вызов `/up` после commit выполняют одну попытку; их replay при восстановлении допускает две. Опция не создаёт новых циклов повторов. Она не продлевает `web.timeouts.bridge_retry_secs` и сроки запуска или восстановления.
## Ориентировочная временная шкала
**Поясняющая модель, а не бенчмарк и не норматив задержки.** Числа подобраны, чтобы показать сложение этапов. Они не восстанавливают наблюдавшиеся 1000–1500 мс. Условия примера: DNS уже закеширован, RTT от клиента до фронтенда — 40 мс, TCP-соединение новое, при необходимости выполняется полный TLS 1.3 handshake, ключ авторизации DC уже есть, потерь и сброса нативной временной метки запроса нет. Базовый вариант — Classic/DD. Для EE собственная подготовка FakeTLS добавляется до нативного таймера.
В этом примере подготовка обычного MTProxy занимает 40 мс на TCP. Подготовка WEB занимает 750 мс: 50 мс на запуск клиента/моста + 40 мс TCP + 40 мс TLS + 120 мс HTTP-обмена загрузки моста/создания сессии + 500 мс ожидания Android checker. Здесь выбраны последовательные длительности; в действительности этапы могут перекрываться. Начальный TLS и ожидание 500 мс находятся вне отображаемого ping.
Диаграмма начинается с **последней временной метки запроса** каждой проверки, а не с запуска приложения или первой попытки соединения. Горизонтальная ось показывает секунды с точностью до миллисекунд (0.200 = 200 мс). Ширина полос пропорциональна длительности. Каждая секция — отдельный сценарий; его этапы выполняются последовательно, а не параллельно. Начальная подготовка остаётся за пределами диаграммы.
```mermaid
%%{init: {"theme": "neutral", "gantt": {"leftPadding": 200, "rightPadding": 100, "barHeight": 24, "barGap": 8, "fontSize": 14, "sectionFontSize": 14}}}%%
gantt
dateFormat x
axisFormat %S.%L
tickInterval 100millisecond
todayMarker off
title Отображаемый ping - jitter выключен
accTitle: Отображаемый ping - jitter выключен
accDescr: Секунды от метки запроса, подписи этапов в миллисекундах
section Classic/DD - 100 мс
R 100 мс :n_r, 0, 100ms
section WEB без ошибок - 200 мс
U 40 мс :w_u, 0, 40ms
R 100 мс :w_r, after w_u, 100ms
D 60 мс :w_d, after w_r, 60ms
section WEB с повтором - 490 мс
F 40 мс :crit, f_u, 0, 40ms
Ожидание 250 мс :crit, retry_wait, after f_u, 250ms
U 40 мс :retry_u, after retry_wait, 40ms
R 100 мс :retry_r, after retry_u, 100ms
D 60 мс :retry_d, after retry_r, 60ms
```
Длительности этапов: `R` = 100 мс базовой работы MTProxy relay/сети/DC для запроса и ответа. `U` = 40 мс дополнительной работы WEB на отправке/OPEN/клиенте. `D` = 60 мс дополнительной работы WEB на приёме и доставке. `F` = 40 мс одной неудачной попытки upload. `Ожидание` = 250 мс ожидания по `X-Telemt-Retry-After-Ms: 250`. `U` и `D` — условные дополнительные затраты сверх `R`, а не повторный учёт полного сетевого RTT. Ожидание ACK/commit учитывается лишь в той мере, в какой оно действительно задержало доставку.
| Сценарий | До таймера ping | Отображаемый ping | Полное время проверки в модели |
| --- | --- | --- | --- |
| Classic/DD | 40 мс | 100 мс | 140 мс |
| WEB без ошибок | 750 мс | 40 + 100 + 60 = 200 мс | 950 мс |
| WEB с одной неудачной загрузкой и одним ожиданием повтора | 750 мс | 40 + 250 + 40 + 100 + 60 = 490 мс | 1240 мс |
| WEB, тот же повтор с uniform jitter 0–250 мс | 750 мс | 490–740 мс | 1240–1490 мс |
Ещё одно TCP + TLS 1.3 соединение, открытое **внутри** замера, могло бы добавить здесь около 80 мс при полностью последовательном выполнении: замер 200 мс превратился бы в 280 мс. При переиспользовании соединения затраты на этот дополнительный handshake равны нулю. Замена начального полного TLS 1.3 handshake на TLS 1.2 при этих условиях добавляет примерно 40 мс к подготовке, а не к показанному ping. В примерах не учтены обработка TLS, возобновление сессии и потери пакетов.
Строка с 490 мс и диаграмма показывают один повтор при выключенном jitter. Дополнительная строка таблицы применяет к тому же повтору `enabled = true`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`; её диапазон получен из модели, а не измерений. Прежний JavaScript-мост без миллисекундного расширения или посредник, удаляющий эту подсказку при выключенном jitter, сохраняет стандартную секундную паузу: отображаемый ping в той же модели составит 1240 мс. Ни один сценарий не устанавливает причину исходного наблюдения. Если повторы не подтверждены трассировкой, ищите другие задержки: очереди, буферизацию фронтенда, открытие соединений или upstream. Не подставляйте таймауты вместо пауз и не переносите перекрывающиеся этапы в эту последовательную модель без проверки.
## HTTP, WebSocket и другие источники задержки
Ответы Telemt при перегрузке содержат `Retry-After: 1` и `X-Telemt-Retry-After-Ms: 250`. Только статус `503` с этой точной парой заголовков задаёт сгенерированному мосту **базовую паузу 250 мс**. При `web.retry_jitter.enabled = false` пауза равна 250 мс; при включении добавляется настроенная случайная задержка. Для остальных ответов действуют стандартная обработка `Retry-After` или резервный backoff, описанные ниже. Ранее загруженные страницы JavaScript-моста без поддержки миллисекундной подсказки используют секундную паузу до загрузки новой страницы; обновлять приложение Telegram для этого не нужно. Лимиты попыток, абсолютные бюджеты, отмена и ограничения admission не меняются.
| Фактор | Влияние и подтверждение |
| --- | --- |
| HTTP uplink | Генерируемый мост предлагает `X-Telemt-Up-Window: 4`; сервер принимает 1–4, при отсутствии согласования используется 1. Окно 1 ждёт ACK перед следующей загрузкой, окно 4 допускает ограниченный параллелизм. Не весь HTTP работает по схеме «отправить и ждать». См. [согласование][runtime] и [обработчик сессии][session]. |
| Первый downlink | `/down` может запускаться одновременно с `/up`; обязательного дополнительного последовательного ACK RTT нет. Первичная доставка в нативный клиент ждёт первого корректного upload ACK/commit. Кадры доставляются по мере поступления. См. [conveyor][conveyor] и [downlink][downlink]. |
| Long polling | `long_poll_secs=25` ограничивает ожидание при отсутствии данных. Готовые данные немедленно будят poll. Промежутки между запросами и занятые слоты соединений браузера всё же могут задержать доставку. См. [серверный polling][poll]. |
| Общие ресурсы транспорта | `https` использует общую очередь загрузки; `https-lanes` разделяет логические линии. WebSocket после Upgrade передаёт кадры без POST на каждый кадр; `websocket-lanes` разделяет сокеты. Сеть, flow control и ресурсы WebView остаются общими. HTTP/2 мультиплексирует потоки, но не устраняет все задержки от перегрузки и flow control. См. [HTTP/2][http2] и [руководство по транспортам](WEB_PROXY.ru.md). |
| Буферизация фронтенда | Буферизация запросов/ответов или отсутствие поддержки streaming/Upgrade могут задерживать короткие сообщения. Сверьте настройки с существующим примером NGINX, сохраняя маршрутизацию, аутентификацию и лимиты. |
| Планирование клиента | Java accept/read, UI dispatch, WebView/JavaScript и возврат в нативный код добавляют очереди. Нагрузка CPU, энергосбережение и смена мобильной сети могут усиливать задержки. |
| Пересылка и сеть | Могут учитываться оставшийся admission потока, внутренний MTProxy-handshake, подключение Direct или регистрация ME, ответ Telegram, потери/повторная передача и очереди. Готовая WEB-сессия не доказывает готовность upstream. |
| Ошибки и повторы | Повторяемые статусы: 408/429/502/503/504. При выключенном jitter точная подсказка Telemt задаёт паузу 250 мс; без положительной подсказки сервера backoff начинается с 250 мс и удваивается до базовых 2000 мс плюс прежний jitter менее 25%. При включённом jitter пауза равна положительной подсказке сервера или этой базе **плюс** настроенная случайная добавка, заменяющая прежний jitter. Бюджеты и ограничения попыток отдельных вызовов сохраняются. Фиксируйте политику загруженного моста и фактическое время повторов. См. [ответы][response] и [повторы][request]. |
Дедлайны не являются обязательными паузами. Стандартные 10 с для запроса моста, 90 с бюджета повторов, 30 с ожидания первого байта потока и 10 с handshake потока нельзя складывать в обычный ping. `carrier_probe_coalesce_ms=0` отключает соответствующую дополнительную задержку объединения проб. Дедлайны переключения транспорта относятся к неудачным попыткам, а не к каждому успешному обмену. См. [значения по умолчанию](../../src/config/types/web/defaults.rs).
Условно WEB-замер включает **оставшуюся подготовку + передачу через транспорт и сеть + очереди + ответ Telegram + задержки повторов, попавшие внутрь таймера**. Этапы перекрываются; складывать все таймауты или назначать отдельный RTT каждому HTTP endpoint некорректно. Готовый транспорт может сократить подготовку, но Android проверяет прокси через отдельный транспорт. WebSocket не всегда быстрее; используйте только поддерживаемые клиентом транспорты. Для совместимости с текущим iOS используется `https`.
## Диагностика с сохранением защит
1. Запишите клиент/сборку, версию Android WebView, политику `web.retry_jitter` загруженного моста, транспорт, публичный endpoint, семейство IP, сеть, маршрут Direct/ME, DC и нагрузку. Сравнивайте при одинаковых условиях. Учитывайте двухминутное кеширование Android: повторное чтение интерфейса не означает новый замер.
2. Разделяйте три эксперимента: новую проверку в списке прокси; новый логический поток на готовом транспорте; прикладной RPC в установленной MTProto-сессии. Последние два требуют подходящих средств измерения в клиенте и не предоставляются автоматически списком прокси. Собирайте отдельные замеры и распределения, а не сравнивайте единичный холодный замер с прогретым.
3. Используйте аутентифицированный `GET /web-status` и существующие настройки `[web.debug]`: `capture_timings=true`, `capture_frames=true`, `body_capture="metadata"`. Включите ограниченный сбор по [руководству диагностики WEB](WEB_PROXY.ru.md), затем восстановите предыдущее состояние. Payload, capabilities и секреты для этой задачи не нужны.
4. Сопоставьте создание сессии, выбор/commit/ошибку транспорта, `stream_admitted`, `stream_first_byte`, `handshake_succeeded`, OPEN/DATA, HTTP-статусы и повторы. `relay_started` предшествует работе с upstream и не означает готовность DC. `GET /v1/runtime/web/status` и `GET /v1/runtime/web/sessions` дают контекст ёмкости и давления очередей, а не сквозной RTT.
5. `request_body_us`, `response_ready_us` и `response_body_us` — смещения от входа в обработчик. Это не независимые длительности для сложения и не моменты отправки в сеть или TCP ACK от другой стороны. Сравнивайте смещения там, где они записаны и имеют подходящий смысл. См. [определения timing-полей][timings].
6. Добавьте измерения соединений/TLS/запросов фронтенда и временные метки клиента, чтобы найти задержку вне Telemt. Проверьте повторное использование соединений, согласованный HTTP, буферизацию и Upgrade. Sideband-события запуска моста/HELLO не дают полную временную шкалу клиента. Для сравнения разных хостов синхронизируйте часы либо используйте локальные длительности.
7. При ME сопоставьте существующий trace `ME RTT sample` (`rtt_ms`, `ema_ms`, `base_ms`, `degraded`). Метка ставится до очереди writer/flush; EMA состоит из 80% прежнего значения и 20% нового замера. Планирование keepalive до этой метки не учитывается. Низкий ME RTT при высоком Android ping указывает на работу вне измеренного участка ME, но не определяет конкретную причину. См. [writer][writer] и [reader][reader].
Примеры интерпретации: быстрая обработка Telemt при поздней доставке клиенту направляет проверку к фронтенду, сети и очередям клиента; `503` и его retry-заголовки определяют базовую задержку, а политика jitter загруженного моста и клиентские отметки времени позволяют установить фактическую паузу; длительная работа upstream требует изучения Direct/ME. Ни один из признаков сам по себе не доказывает, что 1000–1500 мс вызваны криптографией TLS. При сборе данных сохраняйте совместимость, проверку сертификатов, лимиты admission и backpressure.
## Границы подтверждения
Документ описывает изученные исходники, а не сквозной тест на устройствах. Ссылки Android/iOS закрепляют указанные ревизии; ссылки Telemt ведут к сопровождающей документ реализации рабочего дерева, включая локальные WEB-изменения, и не утверждают идентичность всех опубликованных пакетов. Документация не меняет клиент, сервер, таймауты или настройки безопасности.
[android-entry]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/tgnet/ConnectionsManager.java#L752-L765
[android-tester]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyConnectionTester.java#L111-L172
[android-transport]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/utils/proxy/WebProxyTransport.java
[android-native]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionsManager.cpp
[android-socket]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/ConnectionSocket.cpp
[android-key]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/jni/tgnet/Datacenter.cpp#L1312-L1323
[android-ui]: https://github.com/DrKLO/Telegram/blob/3de37c8cfb039452be7398909461da6288babf15/TMessagesProj/src/main/java/org/telegram/ui/ProxyListActivity.java#L671-L687
[ios-filter]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/TelegramCore/Sources/Network/ProxyServersStatuses.swift#L54-L65
[ios-probe]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/MtProtoKit/Sources/MTProxyConnectivity.m#L57-L99
[ios-ui]: https://github.com/TelegramMessenger/Telegram-iOS/blob/279f89e4d4f63f17227bf877dc3d8990f22ecd58/submodules/SettingsUI/Sources/Data%20and%20Storage/ProxyListSettingsController.swift#L273-L310
[tls13]: https://datatracker.ietf.org/doc/html/rfc8446#section-2
[tls12]: https://datatracker.ietf.org/doc/html/rfc5246#section-7.3
[http1]: https://www.rfc-editor.org/rfc/rfc9112#section-9.3
[http2]: https://datatracker.ietf.org/doc/html/rfc9113#section-5
[session]: ../../src/web/http/session.rs
[backend]: ../../src/web/session/backend.rs
[runtime]: ../../src/web/bridge/runtime.js
[conveyor]: ../../src/web/bridge/conveyor.js
[downlink]: ../../src/web/bridge/downlink.js
[poll]: ../../src/web/session/downlink.rs
[request]: ../../src/web/bridge/request.js
[response]: ../../src/web/http/response.rs
[timings]: ../../src/web/trace/types.rs
[writer]: ../../src/transport/middle_proxy/pool_writer.rs
[reader]: ../../src/transport/middle_proxy/reader.rs
+34 -5
View File
@@ -2,6 +2,8 @@
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
[Ping in der Proxyliste, RPC Ping/Pong und WEB-Latenz](PROXY_PING.de.md)
Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS- oder WebSocket-Carrier, die mit dem Proxy-Typ `WEB` von Telegram Desktop kompatibel sind. Telemt terminiert TLS nicht selbst: NGINX, HAProxy oder Caddy verwaltet das öffentliche Zertifikat und leitet unverschlüsseltes HTTP/1.1 an einen privaten Telemt-Listener weiter.
> [!IMPORTANT]
@@ -40,7 +42,7 @@ Leiten Sie den vollständigen konfigurierten WEB-Bereich an Telemt weiter. Beim
- Bei leerem `base_path` lautet der öffentliche Endpunkt `https://HOST:443/`, andernfalls `https://HOST:443/BASE/`. Der Basispfad ist groß-/kleinschreibungssensitiv und exakt; Telemt leitet ihn nicht um, normalisiert ihn nicht und entfernt ihn nicht vor der Decoy-Weiterleitung.
- Unterstützt werden 16-Byte-MTProxy-Secrets in den Modi `plain` und `dd`. FakeTLS-Secrets mit `ee` werden im WEB-Modus nicht unterstützt.
- `web.carrier` wählt den einzigen Carrier bei deaktivierter Auto-Negotiation und den letzten Fallback bei aktivierter Negotiation. `https` verwendet serialisierte HTTPS-Uplinks und Long Polling. `https-lanes` verwendet unabhängige HTTPS-Sequenzen und Polls pro logischem Stream. `websocket` verwendet einen geordneten WebSocket für alle Streams. `websocket-lanes` verwendet einen unabhängig verwalteten WebSocket für jeden logischen Stream ungleich null.
- `web.carrier` wählt den einzigen Carrier bei deaktivierter Auto-Negotiation und den letzten Fallback bei aktivierter Negotiation. `https` verwendet eine gemeinsame HTTPS-Uplink-Sequenz und Long Polling. `https-lanes` verwendet unabhängige HTTPS-Sequenzen und Polls pro logischem Stream. `websocket` verwendet einen geordneten WebSocket für alle Streams. `websocket-lanes` verwendet einen unabhängig verwalteten WebSocket für jeden logischen Stream ungleich null.
- Ein fehlendes `web.carriers` oder `web.carriers = false` deaktiviert Auto-Negotiation und Lernen. Ein nicht leeres Array aktiviert ausschließlich die sequenzielle Start-Negotiation; eine bereits festgeschriebene Sitzung wird nie migriert.
- Native Clients ohne kanonische Carrier-Negotiation-Header verwenden den konfigurierten festen `carrier`, auch wenn `carriers` die Negotiation für fähige Clients aktiviert. Das aktuelle Telegram iOS unterstützt nur `https`; für metadatafreies iOS muss der Betreiber daher `web.carrier = "https"` setzen, `https-lanes` wird nicht unterstützt. User-Agent-Werte einschließlich CFNetwork oder Darwin leiten niemals Capabilities ab. Sendet ein nativer iOS-Client explizite Negotiation-Metadaten, schneidet Telemt sie mit der serverautoritativen Obergrenze `{https}` und lehnt ein leeres Ergebnis ab; andere explizite Clients verwenden ihren angegebenen Capability-Satz.
- Capability-, Bootstrap- und Session-Zugangsdaten sind getrennte Werte mit begrenzter Lebensdauer. Carrier-Zugangsdaten sind geheim und dürfen nicht in Access-Logs erscheinen.
@@ -314,9 +316,11 @@ Versuche laufen streng sequenziell. Akzeptierter `OPEN`- oder `DATA`-Fortschritt
Nach dem Commit wiederholt ein HTTP-Fehler zunächst den exakten unveränderlichen Request mit dem aktuellen Bearer. Ein erfolgreicher Replay behält die aktuelle Sitzung. WebSocket-Verlust oder ein Foreground-, Online- oder natives Ereignis nach mindestens `reconnect_grace_secs` Scheduler-Lücke startet eine Recovery-Epoche. Die Bridge sendet genau einen GET an ihren ursprünglichen konfigurierten Basis-Root mit `bridge=<capability>`, `Accept: application/vnd.telemt.web-recovery+json` und optionaler aktueller Bearer-Authorization. Eine positive Antwort ist ein nicht cachebares JSON-Dokument mit höchstens 1024 Bytes, einem frischen Bootstrap und den aktuellen Limits, Timeouts sowie der Negotiation-Richtlinie. Telemt gibt diesen Bootstrap aus, bevor eine passende aktuelle Sitzung synchron beendet wird, sodass eine Neuerstellung auch bei Kapazität für nur eine Sitzung möglich bleibt. Unbekannte oder bereits beendete Bearer erhalten dieselbe positive Repräsentation; fehlerhafte Recovery-Header sowie deaktivierte Admission, Pause, Drain und Kapazitätsablehnung folgen dem bereinigten Decoy-Pfad.
Die Recovery-Epoche besitzt eine gemeinsame absolute Wall-/Monotonic-Deadline `bridge_recovery_secs`, genau einen Request für das Recovery-Dokument und begrenzte Carrier-Wiederholungen mit Backoff von 250 ms bis 2 s. Während der Recovery wird der Status höchstens alle 2,5 Sekunden wiederholt. Eine frische Inkarnation bricht alte Requests, Sockets, Lanes und Queues ab und gibt sie frei, sendet für jeden noch aktiven nativen Stream genau ein synthetisches `CLOSE`, unterdrückt ein zweites `WELCOME` und committed erst nach echtem Carrier-Fortschritt. Beendete Stream-IDs bleiben in einer begrenzten Menge, damit gültige verspätete Frames nicht in einen neuen Stream gelangen; die native Seite muss eine neue Stream-ID vergeben. Häufige native Reconnect-Versuche sind zulässig, verlängern aber weder die Recovery-Epoche noch halten sie alten Inkarnationszustand. Das Zerstören der WebView zerstört auch diesen Recovery-Owner; ein nativer Supervisor muss danach ein neues Bridge-Dokument erzeugen.
Die Recovery-Epoche besitzt eine gemeinsame absolute Wall-/Monotonic-Deadline `bridge_recovery_secs`, genau einen Request für das Recovery-Dokument und begrenzte Carrier-Wiederholungen mit einer Backoff-Basis von 250 ms bis 2 s ohne Serverhinweis, unter Berücksichtigung von Serverhinweisen und der Jitter-Richtlinie der Seite. Während der Recovery wird der Status höchstens alle 2,5 Sekunden wiederholt. Eine frische Inkarnation bricht alte Requests, Sockets, Lanes und Queues ab und gibt sie frei, sendet für jeden noch aktiven nativen Stream genau ein synthetisches `CLOSE`, unterdrückt ein zweites `WELCOME` und committed erst nach echtem Carrier-Fortschritt. Beendete Stream-IDs bleiben in einer begrenzten Menge, damit gültige verspätete Frames nicht in einen neuen Stream gelangen; die native Seite muss eine neue Stream-ID vergeben. Häufige native Reconnect-Versuche sind zulässig, verlängern aber weder die Recovery-Epoche noch halten sie alten Inkarnationszustand. Das Zerstören der WebView zerstört auch diesen Recovery-Owner; ein nativer Supervisor muss danach ein neues Bridge-Dokument erzeugen.
Jede reguläre HTTP-Carrier-Operation der Bridge besitzt ein absolutes Budget `bridge_retry_secs` und höchstens neun Versuche. `bridge_request_secs` umfasst sowohl den Fetch-Response-Head als auch das vollständige Lesen des Response-Bodys; ein Downlink-Versuch erhält zusätzlich das konfigurierte Long-Poll-Intervall. Netzwerkfehler und Antworten mit `408`, `429`, `502`, `503` oder `504` verwenden begrenzten exponentiellen Backoff, während `Retry-After` das absolute Budget nicht verlängern kann. `carrier_probe_coalesce_ms = 0` sendet den ersten geordneten `OPEN`-Probe sofort. Ein Wert bis 10 ms kann passendes `DATA` aus diesem Fenster aufnehmen; multiplexierte Carrier bewahren die vollständige vorhergehende Frame-Reihenfolge, Lane-Carrier beanspruchen nur die ausgewählte Lane. Vor der Probe-Bestätigung startet kein HTTP-Downlink. Ein multiplexierter WebSocket-Upgrade kann unmittelbar nach seiner Auswahl durch `/session` beginnen und danach eingereihte Probe-Daten aufnehmen; ein Lane-WebSocket wartet auf die bekannte Stream-ID.
Überlastungsantworten von Telemt enthalten `Retry-After: 1` und `X-Telemt-Retry-After-Ms: 250`. Nur Status `503` mit diesem exakten Header-Paar legt in der generierten Bridge eine **Basispause von 250 ms** fest. Bei `web.retry_jitter.enabled = false` beträgt die Pause 250 ms; bei aktiviertem Jitter kommt der konfigurierte zufällige Zuschlag hinzu. Andere Antworten verwenden die unten beschriebene Standardbehandlung von `Retry-After` oder den Backoff ohne Serverhinweis. Frühere JavaScript-Bridge-Seiten ohne Unterstützung des Millisekundenhinweises verwenden bis zum Laden einer neuen Seite die Sekundenpause; ein Update der Telegram-App ist dafür nicht erforderlich. Versuchslimits, absolute Budgets, Abbruchverhalten und Zulassungsgrenzen bleiben unverändert.
Jede reguläre HTTP-Carrier-Operation der Bridge besitzt ein absolutes Budget `bridge_retry_secs` und höchstens neun Versuche. `bridge_request_secs` umfasst sowohl den Fetch-Response-Head als auch das vollständige Lesen des Response-Bodys; ein Downlink-Versuch erhält zusätzlich das konfigurierte Long-Poll-Intervall. Netzwerkfehler und Antworten mit `408`, `429`, `502`, `503` oder `504` verwenden einen positiven Serverhinweis oder exponentiellen Backoff mit der jeweiligen Jitter-Richtlinie; `Retry-After` kann das absolute Budget nicht verlängern. `carrier_probe_coalesce_ms = 0` sendet den ersten geordneten `OPEN`-Probe sofort. Ein Wert bis 10 ms kann passendes `DATA` aus diesem Fenster aufnehmen; multiplexierte Carrier bewahren die vollständige vorhergehende Frame-Reihenfolge, Lane-Carrier beanspruchen nur die ausgewählte Lane. Der HTTP-Downlink kann parallel zum ersten Upload starten; die native Zustellung wartet auf dessen erste gültige ACK/Commit-Bestätigung. Ein multiplexierter WebSocket-Upgrade kann unmittelbar nach seiner Auswahl durch `/session` beginnen und danach eingereihte Probe-Daten aufnehmen; ein Lane-WebSocket wartet auf die bekannte Stream-ID.
Response-Bodys werden innerhalb expliziter Endpunktgrenzen gestreamt: `/session` enthält exakt acht Bytes, ein erfolgreicher `/down` höchstens `carrier_batch_bytes`, und bodylose Antworten akzeptieren null Bytes. Deklarierter Überlauf wird vor dem Lesen abgelehnt; ein Überlauf beim Streaming oder zu viele Chunks bricht den Reader ab, und Bodys wiederholbarer Antworten werden vor dem Backoff verworfen. Die terminale Bridge-Bereinigung sendet höchstens ein authentifiziertes `DELETE`. Kanonische Transportfehler werden für Diagnosen nach `X-Carrier-Failure` kopiert, Navigation und explizites Schließen bleiben nicht lernende Gründe.
@@ -326,7 +330,7 @@ Ein festgeschriebener Versuch wird erst healthy, wenn transportspezifische bidir
Das Lernen ist prozesslokal, speicherresident, ausschließlich positiv und durch `max_carrier_learning_entries` begrenzt. Es sortiert nur vom Client unterstützte konfigurierte Kandidaten, hält den konfigurierten Fallback stets zuletzt und bewahrt bei gleichen Scores die Konfigurationsreihenfolge. User-Agent- und Profilevidenz haben Primärgewicht; eine zulässige IP dient nur als Tie-Breaker. IP-Evidenz erfordert genau eine explizite, global routbare `X-Forwarded-For`-Adresse; private, Loopback-, Link-Local-, Carrier-Grade-NAT-, Dokumentations-, Multicast- und entsprechende IPv4-Mapped-Adressen sind ausgeschlossen. Vom Client gemeldete Fehlerkategorien und Request-Latenz sind ausschließlich diagnostisch und erzeugen weder negative noch Ranking-Evidenz. `conservative` erfordert 3 User-Agent-Ergebnisse oder 8 Profilergebnisse aus 4 Kohorten und deaktiviert IP-Evidenz; `balanced` verwendet 2, 6 aus 3 Kohorten und 3 zulässige IP-Ergebnisse; `aggressive` verwendet 1, 4 aus 2 Kohorten und 1 IP-Ergebnis. Ein Generationswechsel mit identischer Learning-Semantik bewahrt die Evidenz und veröffentlicht deren Generation-Fence atomar neu. Das Deaktivieren des Learnings oder eine Änderung von Aggressiveness, Evidenz-Lebensdauer oder Health-Fenster erhöht die Evidenz-Epoche und trennt inkompatiblen Zustand ab; veraltete Ergebnisse können ihn nicht erneut füllen.
`https` bleibt der Default und behält das ursprüngliche serialisierte Verhalten bei. Bei `https-lanes` ist Lane null für Session-Steuerung reserviert, und jeder logische Stream ungleich null erhält eine eigene Lane. Jede Lane besitzt eigene Uplink-Sequenzen, Retry-Digests, Downlink-Cursor, nicht bestätigte Replay-Batches, Queues und einen Newest-Poll-Wins-Lebenszyklus. Ein langsamer Stream blockiert daher keinen anderen Stream auf der WEB-Protokollebene.
`https` bleibt der Default. Das Upload-Fenster wird zwischen 1 und 4 ausgehandelt; die generierte Bridge bietet `X-Telemt-Up-Window: 4` an, während Fenster 1 serielle Uploads beibehält. Bei `https-lanes` ist Lane null für Session-Steuerung reserviert, und jeder logische Stream ungleich null erhält eine eigene Lane. Jede Lane besitzt eigene Uplink-Sequenzen, Retry-Digests, Downlink-Cursor, nicht bestätigte Replay-Batches, Queues und einen Newest-Poll-Wins-Lebenszyklus. Ein langsamer Stream blockiert daher keinen anderen Stream auf der WEB-Protokollebene.
Damit entfällt die Serialisierung zwischen WEB-Streams auf Anwendungsebene. Öffentliches HTTP/2 läuft weiterhin über eine oder mehrere TCP-Verbindungen, sodass Paketverlust Head-of-Line-Blocking auf Transportebene verursachen kann; `https-lanes` ist kein HTTP/3- oder QUIC-Carrier.
@@ -361,6 +365,30 @@ Statische Dateien werden beim Start und bei einem erfolgreichen Konfigurations-R
Alle WEB-Schlüssel und Defaults sind in der [Konfigurationsreferenz](../Config_params/CONFIG_PARAMS.de.md#web) aufgeführt.
### Konfigurierbarer Jitter für HTTP-Wiederholungen
`[web.retry_jitter]` ergänzt jeden bestehenden HTTP-Retry der Bridge um eine unabhängig gewählte Verzögerung: Netzwerkfehler und Antworten mit 408/429/502/503/504. Telegram-Anwendungen müssen nicht geändert werden. Ohne diesen Abschnitt oder mit `enabled = false` bleiben der 250-ms-Hinweis von Telemt und das bisherige Backoff/Jitter-Verhalten erhalten.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Der Bereich beschreibt einen **Zuschlag**, nicht die gesamte Pause: Zusammen mit dem Telemt-Hinweis von 250 ms ergibt dieses Beispiel 250–500 ms. Ohne positiven Serverhinweis bleibt die Basis bei 250, 500, 1000, dann 2000 ms. Aktivierter Jitter ersetzt den bisherigen Jitter von bis zu 25%. Ein gültiger standardmäßiger `Retry-After` wird im aktivierten Modus nicht auf die bisherige Grenze von 30 Sekunden verkürzt. Übersteigt die Pause das verbleibende Operationsbudget, läuft das Budget ohne vorzeitigen Retry ab.
Standardwerte: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, ohne `peak_ms`. Es gilt `0 <= min_ms <= max_ms <= 30000`. `uniform` verteilt ganze Millisekunden gleichmäßig; `triangular` verwendet eine Dreiecksverteilung mit Rundung auf Millisekunden. Der optionale Gipfel liegt standardmäßig in der Mitte und muss innerhalb des Intervalls liegen. Ein expliziter Gipfel bei `uniform`, unbekannte Schlüssel/Verteilungen und ungültige Grenzen werden auch im deaktivierten Zustand abgelehnt. Gleiche Grenzen ergeben einen konstanten Zuschlag.
Die Richtlinie wird pro Bridge-Seite festgeschrieben. Konfigurations-/API-Reload wirkt auf neu geladene Seiten; bestehende Seiten behalten ihre Einstellungen bei Recovery. Recovery-JSON und HTTP-Kompatibilitätsheader bleiben unverändert. Die Auswahl bevorzugt `crypto.getRandomValues`, mit `Math.random` als Kompatibilitätsfallback. Erfolgreicher Datenverkehr wird nicht verzögert. Versuchslimits, absolute Budgets, Abbruch und unveränderte Payload-/Sequence-Wiederholung bleiben erhalten; längere Pausen können die Anzahl der Versuche innerhalb eines Startup-/Recovery-Budgets reduzieren.
Dies streut feste Retry-Intervalle, garantiert aber keinen Schutz vor DPI. Browser-Scheduling, Netzwerkwarteschlangen und andere Protokollzeiten bleiben beobachtbar. Bestehende Zeitleisten zeigen den deaktivierten Standardmodus. Bei aktiviertem Modus wird zu jeder Retry-Pause eine gezogene Verzögerung addiert, nicht zum ersten Verbindungsaufbau oder zu jedem Datenframe.
Die Richtlinie ändert nur bereits vorhandene Retry-Pausen. Strengere Limits einzelner Aufrufe bleiben erhalten: Reguläre `/down`-Polls und `/up`-Aufrufe nach dem Commit führen einen Versuch aus; ihr Recovery-Replay erlaubt zwei. Die Option erzeugt keine neuen Retry-Schleifen. Sie verlängert weder `web.timeouts.bridge_retry_secs` noch Start- oder Recovery-Deadlines.
## TLS-Terminierung mit NGINX
```nginx
@@ -508,9 +536,10 @@ Tragen Sie in `web_trusted_proxy_cidrs` die Adresse ein, von der aus Caddy Telem
| Bestand der WEB-Listener, Bind-Adresse und Vertrauensrichtlinie | Prozesseigen; Telemt neu starten. |
| `web.allow_wildcard_hosts` und jeder Wert in `[web.limits]` | Prozesseigener Speicher- und Ressourcenvertrag; Telemt neu starten. |
| `web.enabled`, Carrier-/Negotiation-Richtlinie, `web.debug`, Timeouts, vhosts, Profile und Decoys | Werden vom Config-Watcher oder durch einen Runtime-Generations-Reload angewendet. |
| `web.retry_jitter` | Reload gilt nur für neu erzeugte Bridge-Seiten. Geladene Seiten behalten ihre Richtlinie auch bei Recovery; Änderungen erfordern eine neue Seite. |
| Änderung des `base_path` eines vhost | Schaltet Routing neuer HTTP-Requests und Capability-Ableitung atomar um. Geben Sie den generierten Link neu aus. Bereits hochgestufte WebSockets und laufende geroutete Austauschvorgänge laufen weiter. Spätere Requests an die alte Basis mit einem prozessauthentischen Bootstrap- oder Session-Token erhalten ein lokales, nicht cachebares `404`; die nun inaktive alte Capability folgt der gewöhnlichen Decoy-Behandlung. Ein bestehender Session-Bearer bleibt nur an der neuen exakten Basis verwendbar, während ein für die alte Capability ausgegebener ungenutzter Bootstrap an der neuen Basis keine Sitzung erstellen kann. |
| Operator-Pause/-Drain-Zustand | Prozesseigen und flüchtig; übersteht einen Generations-Reload, schreibt niemals Konfiguration und wird nach einem Prozessneustart auf `running` zurückgesetzt. |
| Bestehende HTTP-Verbindungen und WEB-Sitzungen | Behalten HTTP-Idle-Grenze, Carrier-Kandidaten, Grenzen, Body-Timeout, Lebensdauer des Replay-Markers geschlossener Token sowie absolute Session-/Negotiation-Deadlines ihres Erstellungszeitpunkts; jede ausgegebene Bridge enthält ihre Request-, Retry-, Recovery- und Probe-Coalescing-Werte. Eine Recovery-Epoche fixiert ihr aktuelles Bridge-Budget; eine erfolgreiche Recovery-Repräsentation aktualisiert die Richtlinie für spätere Epochen und die frische Sitzung. WebSocket-Upgrade-, Open-, Write-, Backpressure- und Eviction-Vorgänge verwenden die unveränderlichen Deadlines der Parent-Sitzung. Neue Bridges verwenden die aktive Richtlinie, neue logische Streams die aktive Relay-Generation. |
| Bestehende HTTP-Verbindungen und WEB-Sitzungen | Behalten HTTP-Idle-Grenze, Carrier-Kandidaten, Grenzen, Body-Timeout, Lebensdauer des Replay-Markers geschlossener Token sowie absolute Session-/Negotiation-Deadlines ihres Erstellungszeitpunkts; jede ausgegebene Bridge enthält ihre Request-, Retry-, Recovery- und Probe-Coalescing-Werte. Eine Recovery-Epoche fixiert ihr aktuelles Bridge-Budget; eine erfolgreiche Recovery-Repräsentation aktualisiert die Richtlinie für spätere Epochen und die frische Sitzung. WebSocket-Upgrade-, Open-, Write-, Backpressure- und Eviction-Vorgänge verwenden die unveränderlichen Deadlines der Parent-Sitzung. Neue Bridges verwenden die aktive Richtlinie, neue logische Streams die aktive Relay-Generation. `web.retry_jitter` und die HTTP-Methode des Carriers bleiben an die Seite gebunden und werden durch Recovery nicht aktualisiert. |
| Beenden des Prozesses | Erfasst den zuletzt geladenen Wert von `web.timeouts.shutdown_secs` einmalig und verwendet dieselbe absolute Deadline für Listener-Acceptoren und Verbindungen sowie WEB-Sitzungen und Hilfstasks. Aufeinanderfolgende Komponenten erhalten keine separaten vollständigen Budgets. |
Jeder logische Stream behält die Client-IP seiner Sitzung und besitzt während der gesamten Relay-Lebensdauer einen prozessweit eindeutigen, von null verschiedenen synthetischen Quellport. Damit bleibt für Direct- und Middle-End-KDF-Routing ein stabiles, kollisionsfreies Quell-/Ziel-Tupel erhalten.
+34 -5
View File
@@ -2,6 +2,8 @@
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
[Proxy-list ping, RPC Ping/Pong and WEB latency](PROXY_PING.en.md)
WEB mode carries ordinary MTProxy streams through bounded HTTPS or WebSocket carriers compatible with Telegram Desktop's `WEB` proxy type. Telemt does not terminate TLS: NGINX, HAProxy, or Caddy owns the public certificate and forwards plain HTTP/1.1 to a private Telemt listener.
> [!IMPORTANT]
@@ -40,7 +42,7 @@ Let `BASE` mean `/` for an empty `base_path`, or `/<base_path>/` otherwise. The
- The public endpoint is `https://HOST:443/` when `base_path` is empty and `https://HOST:443/BASE/` otherwise. The base is case-sensitive and exact; Telemt does not redirect, normalize, or strip it before decoy forwarding.
- `plain` and `dd` 16-byte MTProxy secrets are supported. `ee` FakeTLS secrets are not supported by WEB mode.
- `web.carrier` selects the sole carrier when auto-negotiation is disabled and the final fallback when it is enabled. `https` uses serialized HTTPS uplink and long polling. `https-lanes` uses independent HTTPS sequencing and polling per logical stream. `websocket` uses one ordered WebSocket for all streams. `websocket-lanes` uses one independently owned WebSocket per non-zero logical stream.
- `web.carrier` selects the sole carrier when auto-negotiation is disabled and the final fallback when it is enabled. `https` uses a shared HTTPS uplink sequence and long polling. `https-lanes` uses independent HTTPS sequencing and polling per logical stream. `websocket` uses one ordered WebSocket for all streams. `websocket-lanes` uses one independently owned WebSocket per non-zero logical stream.
- Missing `web.carriers` or `web.carriers = false` disables auto-negotiation and learning. A non-empty array enables startup-only sequential negotiation; it never migrates an already committed session.
- Native clients without canonical carrier-negotiation headers use the configured fixed `carrier`, even when `carriers` enables negotiation for capable clients. Current Telegram iOS supports only `https`, so an operator serving metadata-free iOS clients must set `web.carrier = "https"`; it does not support `https-lanes`. User-Agent values, including CFNetwork or Darwin, never infer capabilities. When a native iOS request does send explicit negotiation metadata, Telemt intersects it with the server-authoritative `{https}` ceiling and rejects an empty result; other explicit clients use their advertised capability set.
- Capability, bootstrap, and session credentials are separate bounded-lifetime values. Carrier credentials must be treated as secrets and must not appear in access logs.
@@ -300,9 +302,11 @@ Attempts are strictly sequential. Accepted `OPEN` or `DATA` progress commits the
After commit, an HTTP failure first replays the exact frozen request against the current bearer. A successful replay keeps the current session. WebSocket loss, or a foreground/online/native event after at least `reconnect_grace_secs` of scheduler gap, starts one recovery epoch. The bridge performs exactly one GET at its original configured base root with `bridge=<capability>`, `Accept: application/vnd.telemt.web-recovery+json`, and optional current bearer authorization. A positive response is an uncacheable JSON document of at most 1024 bytes containing a fresh bootstrap plus current limits, timeouts, and negotiation policy. Telemt issues that bootstrap before synchronously retiring a matching current session, so recreation remains possible with a one-session capacity. Unknown or already retired bearer authorization receives the same positive representation; malformed recovery headers, disabled admission, pause, drain, and capacity rejection follow the sanitized decoy path.
The recovery epoch has one dual wall/monotonic absolute `bridge_recovery_secs` deadline, a single recovery-document request, and bounded carrier retries with 250 ms through 2 s backoff. Recovery status is repeated at most every 2.5 seconds while active. A fresh incarnation aborts and releases old requests, sockets, lanes, and queues, sends one synthetic `CLOSE` for each still-active native stream, suppresses a second `WELCOME`, and commits only after real carrier progress. Retired stream IDs are retained in a bounded set so valid late frames cannot enter a new stream; the native side must allocate a new stream ID. Frequent native reconnect attempts are valid, but they neither extend the recovery epoch nor retain old incarnation state. Destroying the WebView destroys this recovery owner; a native supervisor must then create a new bridge document.
The recovery epoch has one dual wall/monotonic absolute `bridge_recovery_secs` deadline, a single recovery-document request, and bounded carrier retries with a 250 ms through 2 s fallback base, subject to server hints and the page’s jitter policy. Recovery status is repeated at most every 2.5 seconds while active. A fresh incarnation aborts and releases old requests, sockets, lanes, and queues, sends one synthetic `CLOSE` for each still-active native stream, suppresses a second `WELCOME`, and commits only after real carrier progress. Retired stream IDs are retained in a bounded set so valid late frames cannot enter a new stream; the native side must allocate a new stream ID. Frequent native reconnect attempts are valid, but they neither extend the recovery epoch nor retain old incarnation state. Destroying the WebView destroys this recovery owner; a native supervisor must then create a new bridge document.
Each ordinary bridge carrier HTTP operation has an absolute `bridge_retry_secs` budget and at most nine attempts. `bridge_request_secs` covers both the Fetch response head and complete response body; a downlink attempt additionally receives the configured long-poll interval. Network failures and `408`, `429`, `502`, `503`, or `504` responses use bounded exponential backoff, while `Retry-After` cannot extend the absolute budget. `carrier_probe_coalesce_ms = 0` sends the first ordered `OPEN` probe immediately. A value up to 10 ms may include matching `DATA` that arrives in that window; multiplexed carriers preserve the complete preceding frame order, while lane carriers claim only the selected lane. No HTTP downlink starts before the probe acknowledgement. Multiplexed WebSocket Upgrade may begin as soon as `/session` selects it and then absorbs queued probe data; a lane WebSocket waits until its stream ID is known.
Telemt overload responses carry `Retry-After: 1` and `X-Telemt-Retry-After-Ms: 250`. Only status `503` with that exact header pair selects a **250 ms base delay** in the generated bridge. With `web.retry_jitter.enabled = false`, the pause is 250 ms; when enabled, the configured random addition applies. Other responses use the standard `Retry-After` handling or fallback backoff described below. Earlier JavaScript bridge pages without support for the millisecond hint use the one-second fallback until replaced by a newly loaded page; this does not require updating the Telegram application. Attempt limits, absolute budgets, cancellation and admission limits remain unchanged.
Each ordinary bridge carrier HTTP operation has an absolute `bridge_retry_secs` budget and at most nine attempts. `bridge_request_secs` covers both the Fetch response head and complete response body; a downlink attempt additionally receives the configured long-poll interval. Network failures and `408`, `429`, `502`, `503`, or `504` responses use a positive server hint or exponential fallback backoff, with the applicable jitter policy; `Retry-After` cannot extend the absolute budget. `carrier_probe_coalesce_ms = 0` sends the first ordered `OPEN` probe immediately. A value up to 10 ms may include matching `DATA` that arrives in that window; multiplexed carriers preserve the complete preceding frame order, while lane carriers claim only the selected lane. HTTP downlink can start alongside the first upload; native delivery waits for the first valid upload ACK/commit. Multiplexed WebSocket Upgrade may begin as soon as `/session` selects it and then absorbs queued probe data; a lane WebSocket waits until its stream ID is known.
Response bodies are streamed into explicit endpoint bounds: `/session` is exactly eight bytes, a successful `/down` is at most `carrier_batch_bytes`, and bodyless responses accept zero bytes. Declared overflow is rejected before reading, streamed overflow or excessive chunk count cancels the reader, and retryable response bodies are canceled before backoff. Terminal bridge cleanup sends at most one authenticated `DELETE`; canonical transport failures are copied to `X-Carrier-Failure` for diagnostics, while navigation and explicit close remain non-learning reasons.
@@ -312,7 +316,7 @@ A committed attempt becomes healthy only after transport-specific bidirectional
Learning is process-local, in-memory, positive-only, and bounded by `max_carrier_learning_entries`. It ranks only client-supported configured candidates, keeps the configured fallback last, and uses configured order for equal scores. User-Agent and profile evidence have primary weight; an eligible IP is only a tie-breaker. IP evidence requires exactly one explicit, globally routable `X-Forwarded-For` address; private, loopback, link-local, carrier-grade NAT, documentation, multicast, and IPv4-mapped equivalents are excluded. Client-reported failure categories and request latency are diagnostics, not negative or ranking evidence. `conservative` requires 3 User-Agent outcomes or 8 profile outcomes across 4 cohorts and disables IP evidence; `balanced` uses 2, 6 across 3, and 3 eligible-IP outcomes; `aggressive` uses 1, 4 across 2, and 1 eligible-IP outcome. A generation change with identical learning semantics preserves evidence and atomically republishes its generation fence. Disabling learning or changing aggressiveness, evidence lifetime, or health window advances the evidence epoch and detaches incompatible state; stale outcomes cannot repopulate it.
`https` remains the default and preserves the original serialized behavior. `https-lanes` assigns lane zero to session control and one lane to every non-zero logical stream. Each lane has its own uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, queue, and newest-poll-wins lifecycle. A slow stream therefore does not block another stream at the WEB protocol layer.
`https` remains the default. Its upload window is negotiated from 1 to 4; the generated bridge advertises `X-Telemt-Up-Window: 4`, while window 1 retains serialized uploads. `https-lanes` assigns lane zero to session control and one lane to every non-zero logical stream. Each lane has its own uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, queue, and newest-poll-wins lifecycle. A slow stream therefore does not block another stream at the WEB protocol layer.
This removes application-level serialization between WEB streams. Public HTTP/2 still runs over one or more TCP connections, so packet loss can cause transport-level head-of-line blocking; `https-lanes` is not an HTTP/3 or QUIC carrier.
@@ -347,6 +351,30 @@ Static files are read at startup and successful configuration reload. Entry coun
All WEB keys and defaults are listed in the [configuration reference](../Config_params/CONFIG_PARAMS.en.md#web).
### Configurable HTTP retry jitter
`[web.retry_jitter]` adds an independently sampled delay to every existing bridge HTTP retry (network errors and 408/429/502/503/504). It requires no Telegram application changes. Without this section, or with `enabled = false`, the existing 250 ms Telemt hint and legacy backoff/jitter remain unchanged.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
The range describes an **addition**, not the total pause: the Telemt 250 ms hint plus this example gives 250–500 ms. Without a positive server hint, the base remains 250, 500, 1000, then 2000 ms. Enabled jitter replaces the legacy 25% jitter. A valid standard `Retry-After` is not shortened to the legacy 30-second cap in enabled mode. If the pause exceeds the remaining operation budget, the budget expires without an early retry.
Defaults are `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, and no `peak_ms`. Require `0 <= min_ms <= max_ms <= 30000`. `uniform` samples integer milliseconds; `triangular` samples a triangular density and rounds to milliseconds. Its optional peak defaults to the midpoint and must be within the interval. An explicit peak with `uniform`, unknown keys/distributions and invalid ranges are rejected even when disabled. Equal bounds produce a constant addition.
The policy is frozen into each bridge page. Config/API reload affects newly loaded pages; existing pages retain it through recovery. Recovery JSON and HTTP compatibility headers remain unchanged. Sampling prefers `crypto.getRandomValues`, with `Math.random` as a compatibility fallback. Successful traffic is not delayed. Attempt limits, absolute budgets, cancellation and payload/sequence replay remain intact; longer pauses can exhaust a startup/recovery budget sooner in terms of completed attempts.
This reduces fixed retry timing, without guaranteeing resistance to DPI. Browser scheduling, network queues and other protocol timing remain observable. Existing timeline examples describe the default disabled mode. To evaluate enabled mode, add a sampled delay to each retry pause, not to the initial connection or every data frame.
The policy changes existing retry waits only. Stricter per-call limits still apply: ordinary `/down` polls and committed `/up` calls use one attempt; their recovery replay permits two. This option does not create new retry loops. It neither extends `web.timeouts.bridge_retry_secs` nor any startup/recovery deadline.
## NGINX TLS termination
```nginx
@@ -494,9 +522,10 @@ Set `web_trusted_proxy_cidrs` to the address Caddy uses to connect to Telemt, fo
| WEB listener inventory, bind address, and trust policy | Process-owned; restart Telemt. |
| `web.allow_wildcard_hosts` and any `[web.limits]` value | Process-owned memory/resource contract; restart Telemt. |
| `web.enabled`, carrier/negotiation policy, `web.debug`, timeouts, vhosts, profiles, and decoys | Applied by the config watcher or a runtime generation reload. |
| `web.retry_jitter` | Reload applies to newly rendered bridge pages only. Existing pages retain their policy through recovery; load a new page to adopt changes. |
| A vhost `base_path` change | Atomically switches new HTTP routing and capability derivation. Reissue the generated link. Already upgraded WebSockets and in-flight routed exchanges continue. Later old-base requests carrying a process-authentic bootstrap or session token receive a local no-store `404`; the now-inactive old capability follows ordinary decoy handling. An existing session bearer remains usable only on the new exact base, while an unused bootstrap issued for the old capability cannot create a session on the new base. |
| Operator pause/drain state | Process-owned and ephemeral; survives generation reload, never writes config, and resets to `running` after process restart. |
| Existing HTTP connections and WEB sessions | Keep their acquisition-time HTTP idle limit, carrier candidates, limits, body timeout, closed-token replay lifetime, and absolute session/negotiation deadlines; each issued bridge embeds its request, retry, recovery, and probe-coalescing values. A recovery epoch freezes its current bridge budget, while a successful recovery representation refreshes the policy used by later epochs and the fresh session. WebSocket upgrade, open, write, backpressure, and eviction operations use the parent session's frozen deadlines. Newly issued bridges use the active policy, while new logical streams use the active relay generation. |
| Existing HTTP connections and WEB sessions | Keep their acquisition-time HTTP idle limit, carrier candidates, limits, body timeout, closed-token replay lifetime, and absolute session/negotiation deadlines; each issued bridge embeds its request, retry, recovery, and probe-coalescing values. A recovery epoch freezes its current bridge budget, while a successful recovery representation refreshes the policy used by later epochs and the fresh session. WebSocket upgrade, open, write, backpressure, and eviction operations use the parent session's frozen deadlines. Newly issued bridges use the active policy, while new logical streams use the active relay generation. `web.retry_jitter` and the HTTP carrier method remain page-owned and are not refreshed by recovery. |
| Process shutdown | Captures the latest reloaded `web.timeouts.shutdown_secs` once and shares that single absolute deadline across listener acceptors and connections plus WEB sessions and auxiliary tasks. The waits do not receive sequential per-component budgets. |
Each logical stream keeps its session's creation-time client IP and owns a process-unique, non-zero synthetic source port for the complete relay lifetime. This preserves one stable, non-colliding source/destination tuple for Direct and Middle-End KDF routing.
+34 -5
View File
@@ -2,6 +2,8 @@
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
[Ping в списке прокси, RPC Ping/Pong и задержки WEB](PROXY_PING.ru.md)
WEB-режим переносит обычные MTProxy-потоки через bounded HTTPS или WebSocket carriers, совместимые с типом прокси `WEB` в Telegram Desktop. Telemt не терминирует TLS: публичный сертификат обслуживает NGINX, HAProxy или Caddy, который передаёт обычный HTTP/1.1 на приватный listener Telemt.
> [!IMPORTANT]
@@ -40,7 +42,7 @@ WEB-listener Telemt
- При пустом `base_path` публичный endpoint имеет вид `https://HOST:443/`, иначе — `https://HOST:443/BASE/`. Base path регистрозависим и проверяется точно; Telemt не перенаправляет, не нормализует и не удаляет его перед отправкой в decoy.
- Поддерживаются 16-байтовые MTProxy-секреты `plain` и `dd`. FakeTLS-секреты `ee` в WEB-режиме не поддерживаются.
- `web.carrier` выбирает единственный carrier при выключенном auto-negotiation и последний fallback при включённом. `https` использует сериализованные HTTPS uplink и long polling. `https-lanes` использует независимые HTTPS sequencing и polling для каждого logical stream. `websocket` использует один упорядоченный WebSocket для всех streams. `websocket-lanes` использует отдельный WebSocket с независимым ownership для каждого ненулевого logical stream.
- `web.carrier` выбирает единственный carrier при выключенном auto-negotiation и последний fallback при включённом. `https` использует общую последовательность HTTPS uplink и long polling. `https-lanes` использует независимые HTTPS sequencing и polling для каждого logical stream. `websocket` использует один упорядоченный WebSocket для всех streams. `websocket-lanes` использует отдельный WebSocket с независимым ownership для каждого ненулевого logical stream.
- Отсутствующий `web.carriers` или `web.carriers = false` отключает auto-negotiation и обучение. Непустой массив включает только стартовый последовательный перебор; уже committed session никогда не мигрирует.
- Нативные клиенты без канонических headers carrier negotiation используют настроенный фиксированный `carrier`, даже когда `carriers` включает negotiation для поддерживающих его клиентов. Текущий Telegram iOS поддерживает только `https`, поэтому для metadata-free iOS оператор должен задать `web.carrier = "https"`; `https-lanes` этим клиентом не поддерживается. User-Agent, включая CFNetwork или Darwin, никогда не выводит capabilities неявно. Если нативный iOS всё же отправляет явные negotiation metadata, Telemt пересекает их с server-authoritative ceiling `{https}` и отклоняет пустой результат; остальные явные клиенты используют заявленный capability set.
- Capability, bootstrap и session credentials — отдельные значения с ограниченным сроком жизни. Carrier credentials считаются секретами и не должны попадать в access logs.
@@ -307,9 +309,11 @@ Bridge отправляет additive status objects v1 с полями `state`,
После commit при HTTP failure сначала точно повторяется замороженный request с текущим bearer. Успешный replay сохраняет текущую session. Потеря WebSocket либо событие foreground, online или native после scheduler gap не короче `reconnect_grace_secs` запускает одну recovery epoch. Bridge выполняет ровно один GET к исходному настроенному base root с `bridge=<capability>`, `Accept: application/vnd.telemt.web-recovery+json` и необязательной authorization текущим bearer. Положительный ответ — не кэшируемый JSON document размером не более 1024 байт со свежим bootstrap и текущими limits, timeouts и negotiation policy. Telemt выдаёт этот bootstrap до синхронного завершения совпавшей текущей session, поэтому пересоздание остаётся возможным при capacity в одну session. Неизвестный или уже retired bearer получает то же положительное representation; malformed recovery headers, выключенная admission, pause, drain и capacity rejection следуют по очищенному decoy path.
Recovery epoch имеет единый абсолютный wall/monotonic deadline `bridge_recovery_secs`, один request recovery document и bounded carrier retries с backoff от 250 мс до 2 с. Активный recovery status повторяется не чаще одного раза в 2,5 секунды. Новая incarnation прерывает и освобождает старые requests, sockets, lanes и queues, отправляет один synthetic `CLOSE` для каждого ещё активного native stream, подавляет второй `WELCOME` и выполняет commit только после реального carrier progress. Retired stream IDs сохраняются в bounded set, чтобы корректные поздние frames не попали в новый stream; native side должна выделить новый stream ID. Частые native reconnect attempts допустимы, но не продлевают recovery epoch и не удерживают старое состояние incarnation. Уничтожение WebView уничтожает этого recovery owner; после этого native supervisor должен создать новый bridge document.
Recovery epoch имеет единый абсолютный wall/monotonic deadline `bridge_recovery_secs`, один request recovery document и bounded carrier retries с резервной базовой паузой от 250 мс до 2 с с учётом подсказок сервера и политики jitter страницы. Активный recovery status повторяется не чаще одного раза в 2,5 секунды. Новая incarnation прерывает и освобождает старые requests, sockets, lanes и queues, отправляет один synthetic `CLOSE` для каждого ещё активного native stream, подавляет второй `WELCOME` и выполняет commit только после реального carrier progress. Retired stream IDs сохраняются в bounded set, чтобы корректные поздние frames не попали в новый stream; native side должна выделить новый stream ID. Частые native reconnect attempts допустимы, но не продлевают recovery epoch и не удерживают старое состояние incarnation. Уничтожение WebView уничтожает этого recovery owner; после этого native supervisor должен создать новый bridge document.
Каждая обычная carrier HTTP-операция bridge имеет абсолютный budget `bridge_retry_secs` и не более девяти attempts. `bridge_request_secs` охватывает Fetch response head и полное чтение response body; для downlink attempt дополнительно разрешён настроенный long-poll interval. Network failures и ответы `408`, `429`, `502`, `503` или `504` используют bounded exponential backoff, а `Retry-After` не может расширить абсолютный budget. При `carrier_probe_coalesce_ms = 0` первый упорядоченный probe с `OPEN` отправляется немедленно. Значение до 10 мс позволяет включить соответствующий `DATA`, пришедший в этом окне; multiplexed carriers сохраняют весь предшествующий порядок frames, а lane carriers забирают только выбранную lane. HTTP downlink не запускается до acknowledgement probe. Multiplexed WebSocket Upgrade может начаться сразу после его выбора ответом `/session` и затем включить queued probe data; lane WebSocket ждёт известного stream ID.
Ответы Telemt при перегрузке содержат `Retry-After: 1` и `X-Telemt-Retry-After-Ms: 250`. Только статус `503` с этой точной парой заголовков задаёт сгенерированному мосту **базовую паузу 250 мс**. При `web.retry_jitter.enabled = false` пауза равна 250 мс; при включении добавляется настроенная случайная задержка. Для остальных ответов действуют стандартная обработка `Retry-After` или резервный backoff, описанные ниже. Ранее загруженные страницы JavaScript-моста без поддержки миллисекундной подсказки используют секундную паузу до загрузки новой страницы; обновлять приложение Telegram для этого не нужно. Лимиты попыток, абсолютные бюджеты, отмена и ограничения admission не меняются.
Каждая обычная carrier HTTP-операция bridge имеет абсолютный budget `bridge_retry_secs` и не более девяти attempts. `bridge_request_secs` охватывает Fetch response head и полное чтение response body; для downlink attempt дополнительно разрешён настроенный long-poll interval. Network failures и ответы `408`, `429`, `502`, `503` или `504` используют положительную подсказку сервера или экспоненциальный backoff с действующей политикой jitter; `Retry-After` не может расширить абсолютный budget. При `carrier_probe_coalesce_ms = 0` первый упорядоченный probe с `OPEN` отправляется немедленно. Значение до 10 мс позволяет включить соответствующий `DATA`, пришедший в этом окне; multiplexed carriers сохраняют весь предшествующий порядок frames, а lane carriers забирают только выбранную lane. HTTP downlink может запускаться одновременно с первым upload; доставка в native-клиент ждёт первого корректного ACK/commit upload. Multiplexed WebSocket Upgrade может начаться сразу после его выбора ответом `/session` и затем включить queued probe data; lane WebSocket ждёт известного stream ID.
Response bodies читаются потоково с явными bounds endpoint: `/session` содержит ровно восемь байт, успешный `/down` — не более `carrier_batch_bytes`, а bodyless responses допускают ноль байт. Заявленный overflow отклоняется до чтения; overflow при streaming или избыточное число chunks отменяет reader, а bodies retryable responses отменяются до backoff. Финальный cleanup bridge отправляет не более одного аутентифицированного `DELETE`; канонические transport failures копируются в `X-Carrier-Failure` для диагностики, а navigation и explicit close остаются non-learning reasons.
@@ -319,7 +323,7 @@ Committed attempt становится healthy, только когда transpor
Обучение process-local, in-memory, positive-only и ограничено `max_carrier_learning_entries`. Оно ранжирует только поддерживаемые клиентом настроенные candidates, всегда оставляет fallback последним и сохраняет настроенный порядок при равных scores. Evidence User-Agent и профиля имеет основной вес; допустимый IP служит только tie-breaker. Для IP evidence требуется ровно один явный глобально маршрутизируемый `X-Forwarded-For`; private, loopback, link-local, carrier-grade NAT, documentation, multicast и их IPv4-mapped эквиваленты исключаются. Категории ошибок от клиента и request latency используются только для диагностики и не создают отрицательный или ranking evidence. `conservative` требует 3 outcomes User-Agent или 8 outcomes профиля в 4 cohorts и отключает IP evidence; `balanced` использует соответственно 2, 6 в 3 cohorts и 3 outcomes допустимого IP; `aggressive` — 1, 4 в 2 cohorts и 1 outcome IP. Смена generation при неизменной learning semantics сохраняет evidence и атомарно перепубликует его generation fence. Выключение обучения или изменение aggressiveness, lifetime evidence либо health window увеличивает evidence epoch и отсоединяет несовместимое состояние; stale outcomes не могут заполнить его снова.
`https` остаётся default и сохраняет исходное сериализованное поведение. В `https-lanes` lane zero отведена под session control, а каждому ненулевому logical stream соответствует своя lane. У каждой lane собственные uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, очередь и lifecycle newest-poll-wins. Поэтому медленный stream не блокирует другой stream на уровне WEB-протокола.
`https` остаётся default. Окно upload согласуется в диапазоне от 1 до 4; сгенерированный мост предлагает `X-Telemt-Up-Window: 4`, а окно 1 сохраняет последовательную отправку. В `https-lanes` lane zero отведена под session control, а каждому ненулевому logical stream соответствует своя lane. У каждой lane собственные uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, очередь и lifecycle newest-poll-wins. Поэтому медленный stream не блокирует другой stream на уровне WEB-протокола.
Это устраняет сериализацию между WEB-streams на уровне приложения. Публичный HTTP/2 всё ещё работает поверх одного или нескольких TCP-connections, поэтому потеря пакетов может вызвать transport-level head-of-line blocking; `https-lanes` не является HTTP/3- или QUIC-carrier.
@@ -354,6 +358,30 @@ index = "index.html"
Все WEB-ключи и defaults перечислены в [справочнике конфигурации](../Config_params/CONFIG_PARAMS.ru.md#web).
### Настраиваемый jitter HTTP-повторов
`[web.retry_jitter]` добавляет независимо выбранную задержку к каждому существующему HTTP-повтору бриджа: сетевым ошибкам и ответам 408/429/502/503/504. Менять приложения Telegram не требуется. Без секции или при `enabled = false` сохраняются подсказка Telemt на 250 мс и прежний backoff/jitter.
```toml
[web.retry_jitter]
enabled = true
min_ms = 0
max_ms = 250
distribution = "uniform"
# Only for distribution = "triangular":
# peak_ms = 125
```
Диапазон задаёт **добавку**, а не полную паузу: подсказка Telemt 250 мс с этим примером даёт 250–500 мс. Без положительной серверной подсказки база остаётся 250, 500, 1000, затем 2000 мс. Включённый jitter заменяет прежний jitter до 25%. Корректный стандартный `Retry-After` во включённом режиме не сокращается прежним пределом в 30 секунд. Если пауза не помещается в оставшийся бюджет операции, бюджет истекает без раннего повтора.
Значения по умолчанию: `enabled = false`, `min_ms = 0`, `max_ms = 250`, `distribution = "uniform"`, `peak_ms` отсутствует. Требуется `0 <= min_ms <= max_ms <= 30000`. `uniform` выбирает целые миллисекунды равномерно; `triangular` использует треугольную плотность с округлением до миллисекунд. Необязательный пик по умолчанию находится посередине и должен входить в диапазон. Явный пик для `uniform`, неизвестные ключи/распределения и неверные границы отклоняются даже при выключенной опции. Одинаковые границы дают постоянную добавку.
Политика фиксируется в каждой странице бриджа. Reload через конфиг/API влияет на новые страницы; активные сохраняют настройки при recovery. Recovery JSON и HTTP-заголовки совместимости не меняются. Используется `crypto.getRandomValues`, при недоступности — `Math.random` для совместимости. Успешный трафик не задерживается. Лимиты попыток, общий бюджет, отмена и повтор неизменных payload/sequence сохраняются; более длинные паузы могут уменьшить число попыток, помещающихся в startup/recovery-бюджет.
Это размывает фиксированные интервалы повторов, но не гарантирует защиту от DPI. Планирование браузера, сетевые очереди и другие временные характеристики протокола остаются наблюдаемыми. Существующие примеры временной шкалы описывают выключенную опцию. Для включённого режима добавляйте выбранную задержку к каждой паузе повтора, а не к первому соединению или каждому кадру данных.
Политика меняет только уже предусмотренные паузы перед повтором. Более строгие ограничения отдельных вызовов сохраняются: обычный poll `/down` и вызов `/up` после commit выполняют одну попытку; их replay при восстановлении допускает две. Опция не создаёт новых циклов повторов. Она не продлевает `web.timeouts.bridge_retry_secs` и сроки запуска или восстановления.
## Терминация TLS на NGINX
```nginx
@@ -501,9 +529,10 @@ log {
| Состав WEB-listeners, bind address и trust policy | Принадлежат процессу; перезапустите Telemt. |
| `web.allow_wildcard_hosts` и любое значение `[web.limits]` | Process-owned контракт памяти и ресурсов; перезапустите Telemt. |
| `web.enabled`, policy carrier/negotiation, `web.debug`, timeouts, vhosts, profiles и decoys | Применяются config watcher или runtime generation reload. |
| `web.retry_jitter` | Reload действует только для новых страниц моста. Уже загруженные страницы сохраняют политику при recovery; для применения изменений нужна новая страница. |
| Изменение `base_path` vhost | Атомарно переключает маршрутизацию новых HTTP-запросов и derivation capability. Выпустите новую сгенерированную ссылку. Уже upgraded WebSockets и начатые маршрутизированные обмены продолжаются. Последующие запросы к старому base с подлинным для процесса bootstrap- или session-token получают локальный no-store `404`, а ставшая неактивной прежняя capability обрабатывается как обычный decoy traffic. Существующий session bearer остаётся пригодным только на новом точном base, а неиспользованный bootstrap, выданный для старой capability, не может создать session на новом base. |
| Operator pause/drain state | Process-owned и ephemeral; переживает generation reload, никогда не записывает конфигурацию и после перезапуска процесса возвращается в `running`. |
| Существующие HTTP connections и WEB sessions | Сохраняют HTTP idle limit, carrier candidates, лимиты, body timeout, lifetime replay-marker закрытого token и абсолютные session/negotiation deadlines своего момента создания; каждый выданный bridge содержит собственные request, retry, recovery и probe-coalescing значения. Recovery epoch фиксирует текущий bridge budget, а успешное recovery representation обновляет policy для последующих epochs и новой session. WebSocket Upgrade, open, write, backpressure и eviction operations используют замороженные deadlines parent session. Новые bridges получают активную policy, а новые logical streams используют активное relay generation. |
| Существующие HTTP connections и WEB sessions | Сохраняют HTTP idle limit, carrier candidates, лимиты, body timeout, lifetime replay-marker закрытого token и абсолютные session/negotiation deadlines своего момента создания; каждый выданный bridge содержит собственные request, retry, recovery и probe-coalescing значения. Recovery epoch фиксирует текущий bridge budget, а успешное recovery representation обновляет policy для последующих epochs и новой session. WebSocket Upgrade, open, write, backpressure и eviction operations используют замороженные deadlines parent session. Новые bridges получают активную policy, а новые logical streams используют активное relay generation. `web.retry_jitter` и HTTP-метод carrier остаются свойствами страницы и не обновляются при recovery. |
| Завершение процесса | Один раз фиксирует последнее применённое значение `web.timeouts.shutdown_secs` и использует единый абсолютный deadline для listener acceptors и connections, WEB sessions и auxiliary tasks. Последовательные компоненты не получают отдельные полные бюджеты. |
Каждый logical stream сохраняет client IP своей сессии и владеет уникальным в пределах процесса ненулевым synthetic source port до завершения relay. Это сохраняет один стабильный непересекающийся source/destination tuple для Direct и Middle-End KDF routing.
+47
View File
@@ -141,3 +141,50 @@ async fn grouped_hosts_cannot_bypass_frozen_process_limits() {
// Effective process policy must reject patterns until the explicit restart has happened.
#[path = "wildcard_tests.rs"]
mod wildcard_tests;
#[tokio::test]
async fn retry_jitter_patch_validates_and_reloads_without_restart() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(&path, source()).unwrap();
let initial = ProxyConfig::load(&path).unwrap();
let (_, revision) = read_managed_config(&path).await.unwrap();
let patch = serde_json::json!({"web":{"retry_jitter":{
"enabled":true,"min_ms":10,"max_ms":300,"distribution":"triangular","peak_ms":100
}}});
let mut response = apply_patch_to_path(&path, &patch, Some(revision))
.await
.unwrap();
let desired = ProxyConfig::load(&path).unwrap();
let resolved = reconcile_runtime_effect(&mut response, &initial, &desired).unwrap();
assert!(resolved.runtime_changed);
assert!(!response.process_restart_required);
assert_eq!(
resolved.effective.web.retry_jitter,
desired.web.retry_jitter
);
assert!(!initial.web.retry_jitter.enabled);
let (managed, revision) = read_managed_config(&path).await.unwrap();
assert_eq!(
managed["web"]["retry_jitter"]["peak_ms"].as_integer(),
Some(100)
);
let before = std::fs::read(&path).unwrap();
for invalid in [
serde_json::json!({"max_ms":30001}),
serde_json::json!({"peak_ms":301}),
serde_json::json!({"distribution":"uniform"}),
serde_json::json!({"typo":1}),
] {
assert!(
apply_patch_to_path(
&path,
&serde_json::json!({"web":{"retry_jitter":invalid}}),
Some(revision.clone())
)
.await
.is_err()
);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
}
+4 -2
View File
@@ -15,10 +15,12 @@ mod atomic;
pub(in crate::api) use atomic::{write_atomic, write_atomic_if_unchanged};
#[cfg(test)]
pub(in crate::api) use persistence::save_access_sections_to_disk;
#[cfg(test)]
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
pub(in crate::api) use persistence::{
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
save_access_sections_to_disk_if_revision, upsert_toml_table,
render_server_listeners, render_top_level_section, save_access_sections_to_disk_if_revision,
upsert_toml_table,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1
View File
@@ -97,6 +97,7 @@ pub(in crate::api) fn render_server_listeners(cfg: &ProxyConfig) -> Result<Strin
}
/// Validates and atomically writes access tables to their single source owner.
#[cfg(test)]
pub(in crate::api) async fn save_access_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
+7 -3
View File
@@ -38,9 +38,9 @@ mod validate_server;
mod validate_web;
mod validation;
use self::includes::{
hash_rendered_snapshot, normalize_config_path, preprocess_includes, read_config_source,
};
#[cfg(test)]
use self::includes::normalize_config_path;
use self::includes::{hash_rendered_snapshot, preprocess_includes, read_config_source};
use self::normalize::{
is_valid_ad_tag, is_valid_tls_domain_name, normalize_domain_to_ascii,
normalize_exclusive_mask_target, normalize_mask_host_to_ascii, parse_exclusive_mask_target,
@@ -167,16 +167,19 @@ pub struct ProxyConfig {
impl ProxyConfig {
/// Loads runtime configuration from a TOML file.
#[cfg(test)]
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
Self::load_with_metadata(path).map(|loaded| loaded.config)
}
/// Loads typed configuration together with its source and rendered metadata.
#[cfg(test)]
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
Self::load_with_source_overrides(path, &BTreeMap::new())
}
/// Loads a typed snapshot while replacing selected captured source documents.
#[cfg(test)]
pub(crate) fn load_with_source_overrides<P: AsRef<Path>>(
path: P,
source_overrides: &BTreeMap<PathBuf, String>,
@@ -247,6 +250,7 @@ impl ProxyConfig {
})
}
#[cfg(test)]
fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
pipeline::load_source_graph(graph)
}
+1
View File
@@ -1,6 +1,7 @@
use super::*;
/// Completes legacy synchronous loads without performing hostname resolution.
#[cfg(test)]
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
finish(parse_source_graph(graph)?)
}
+2
View File
@@ -128,6 +128,7 @@ pub(in crate::config::load) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
group_id: vhost_idx,
profiles: shared_profiles,
}),
#[cfg(test)]
host: String::new(),
base: if vhost.base_path.is_empty() {
"/".to_string()
@@ -155,6 +156,7 @@ pub(in crate::config::load) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
host.clone(),
Arc::new(WebRuntimeVhost {
wildcard: None,
#[cfg(test)]
host: host.clone(),
base: if vhost.base_path.is_empty() {
"/".to_string()
+3
View File
@@ -260,7 +260,10 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"web_trusted_proxy_cidrs",
];
const WEB_RETRY_JITTER_KEYS: &[&str] = &["enabled", "min_ms", "max_ms", "distribution", "peak_ms"];
const WEB_CONFIG_KEYS: &[&str] = &[
"retry_jitter",
"allow_wildcard_hosts",
"enabled",
"carrier",
+8
View File
@@ -36,6 +36,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
WEB_CONFIG_KEYS,
WEB_RETRY_JITTER_KEYS,
WEB_LIMITS_CONFIG_KEYS,
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
WEB_DEBUG_CONFIG_KEYS,
@@ -236,6 +237,13 @@ pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<Unkn
&["web"],
WEB_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "retry_jitter"],
WEB_RETRY_JITTER_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
+5
View File
@@ -75,6 +75,11 @@ pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> {
));
}
config
.web
.retry_jitter
.validate()
.map_err(|message| ProxyError::Config(message.into()))?;
validate_limits(&config.web.limits, config.web.allow_wildcard_hosts)?;
debug::validate(&config.web.debug, &config.web.limits)?;
let carriers = negotiation::validate(&config.web)?;
+2 -1
View File
@@ -58,7 +58,8 @@ pub use server::{
pub use web::{
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig,
WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebWildcardLimitsConfig,
WebRetryJitterConfig, WebRetryJitterDistribution, WebSecretMode, WebTimeoutsConfig,
WebVhostConfig, WebWildcardLimitsConfig,
};
pub(crate) use web::{
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile,
+7
View File
@@ -14,6 +14,9 @@ mod wildcard;
pub use wildcard::WebWildcardLimitsConfig;
// Serialized WEB defaults remain separate from the runtime data model.
mod retry;
pub use retry::{WebRetryJitterConfig, WebRetryJitterDistribution};
mod defaults;
use defaults::*;
// Decoy fast-track policy remains isolated from the bulky WEB data model.
@@ -469,6 +472,9 @@ pub struct WebConfig {
/// WEB lifecycle deadlines.
#[serde(default)]
pub timeouts: WebTimeoutsConfig,
/// Additive HTTP retry jitter frozen into each generated bridge page.
#[serde(default)]
pub retry_jitter: WebRetryJitterConfig,
/// Exact hostname and opted-in wildcard groups served by WEB listeners.
#[serde(default)]
pub vhosts: Vec<WebVhostConfig>,
@@ -517,6 +523,7 @@ impl Default for WebConfig {
limits: WebLimitsConfig::default(),
debug: WebDebugConfig::default(),
timeouts: WebTimeoutsConfig::default(),
retry_jitter: WebRetryJitterConfig::default(),
vhosts: Vec::new(),
runtime: None,
decoy_dns: Arc::default(),
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
/// Shape of the optional additive HTTP retry delay.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebRetryJitterDistribution {
/// Equal probability for each integer delay in the configured interval.
#[default]
Uniform,
/// Continuous triangular density rounded to milliseconds.
Triangular,
}
/// Page-owned retry policy; configuration reload affects newly rendered bridges.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WebRetryJitterConfig {
/// Replaces the legacy jitter while retaining the underlying retry delay.
pub enabled: bool,
/// Inclusive minimum additive delay in milliseconds.
pub min_ms: u32,
/// Inclusive maximum additive delay, bounded to 30000 milliseconds.
pub max_ms: u32,
/// Probability distribution used independently for each retry.
pub distribution: WebRetryJitterDistribution,
/// Triangular mode; omission selects the midpoint of the interval.
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_ms: Option<u32>,
}
impl Default for WebRetryJitterConfig {
fn default() -> Self {
Self {
enabled: false,
min_ms: 0,
max_ms: 250,
distribution: WebRetryJitterDistribution::Uniform,
peak_ms: None,
}
}
}
impl WebRetryJitterConfig {
/// Validates inactive policies too, so later activation cannot expose invalid values.
pub(crate) fn validate(&self) -> Result<(), &'static str> {
if self.min_ms > self.max_ms || self.max_ms > 30000 {
return Err("web.retry_jitter requires 0 <= min_ms <= max_ms <= 30000");
}
if let Some(peak) = self.peak_ms {
if self.distribution != WebRetryJitterDistribution::Triangular
|| peak < self.min_ms
|| peak > self.max_ms
{
return Err(
"web.retry_jitter.peak_ms requires triangular distribution and an in-range peak",
);
}
}
Ok(())
}
/// Emits only typed numeric and boolean literals, without user-controlled script text.
pub(crate) fn bridge_policy(&self) -> String {
let peak = self
.peak_ms
.map(f64::from)
.unwrap_or((f64::from(self.min_ms) + f64::from(self.max_ms)) / 2.0);
format!(
"{{enabled:{},min:{},max:{},peak:{},triangular:{}}}",
self.enabled,
self.min_ms,
self.max_ms,
peak,
self.distribution == WebRetryJitterDistribution::Triangular,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retry_jitter_validation_and_roundtrip() {
let default = WebRetryJitterConfig::default();
assert!(!default.enabled);
assert_eq!(default.validate(), Ok(()));
for (min, max, peak, triangular, valid) in [
(0, 250, None, false, true),
(250, 250, Some(250), true, true),
(0, 30000, Some(0), true, true),
(0, 250, Some(250), true, true),
(251, 250, None, false, false),
(0, 30001, None, false, false),
(0, 250, Some(125), false, false),
(100, 250, Some(99), true, false),
(0, 250, Some(251), true, false),
] {
for enabled in [false, true] {
let policy = WebRetryJitterConfig {
enabled,
min_ms: min,
max_ms: max,
peak_ms: peak,
distribution: if triangular {
WebRetryJitterDistribution::Triangular
} else {
WebRetryJitterDistribution::Uniform
},
};
assert_eq!(policy.validate().is_ok(), valid);
let json = serde_json::to_string(&policy).unwrap();
assert_eq!(
serde_json::from_str::<WebRetryJitterConfig>(&json).unwrap(),
policy
);
}
}
for json in [
r#"{"min_ms":-1}"#,
r#"{"max_ms":1.5}"#,
r#"{"distribution":"normal"}"#,
r#"{"unknown":1}"#,
] {
assert!(serde_json::from_str::<WebRetryJitterConfig>(json).is_err());
}
}
}
+1
View File
@@ -23,6 +23,7 @@ pub(crate) struct WebRuntimeVhost {
/// Shared automatic-host policy; exact hosts keep their precomputed bindings.
pub(crate) wildcard: Option<WebRuntimeWildcard>,
/// Canonical lowercase ACE hostname; empty for a wildcard template.
#[cfg(test)]
pub(crate) host: String,
/// Exact slash-delimited endpoint base, including the trailing slash.
pub(crate) base: String,
+1
View File
@@ -57,6 +57,7 @@ impl PidFile {
}
/// Checks whether the PID file names a running process without modifying either file.
#[cfg(test)]
pub fn check_running(&self) -> Result<Option<i32>, DaemonError> {
let Some(pid) = read_pid_file_if_exists(&self.path, self.strict_runtime_paths)? else {
return Ok(None);
+3 -1
View File
@@ -1,6 +1,8 @@
#[cfg(any(test, not(unix)))]
use std::fs;
use std::fs::File;
#[cfg(not(unix))]
use std::fs::OpenOptions;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
+1
View File
@@ -74,6 +74,7 @@ impl SessionAdmission {
.fetch_or(SESSION_ADMISSION_CLOSED, Ordering::AcqRel);
}
#[cfg(test)]
fn reopen(&self) {
self.state
.fetch_and(!SESSION_ADMISSION_CLOSED, Ordering::AcqRel);
+11 -1
View File
@@ -13,7 +13,7 @@ use crate::web::manager::{HttpConnectionAdmissionError, WebProcessRuntime};
use crate::web::telemetry::{WebHttpConnectionOverloadOutcome, WebRejectionReason};
/// Exact bounded retryable response emitted before HTTP request parsing.
pub(super) const SERVICE_UNAVAILABLE_RESPONSE: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nCache-Control: no-store\r\nRetry-After: 1\r\nConnection: close\r\n\r\n";
pub(super) const SERVICE_UNAVAILABLE_RESPONSE: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nCache-Control: no-store\r\nRetry-After: 1\r\nX-Telemt-Retry-After-Ms: 250\r\nConnection: close\r\n\r\n";
/// Handles one accepted WEB socket outside ordinary connection capacity.
#[allow(clippy::too_many_arguments)]
@@ -164,6 +164,11 @@ mod tests {
let mut bytes = Vec::new();
client.read_to_end(&mut bytes).await.unwrap();
assert_eq!(bytes, super::SERVICE_UNAVAILABLE_RESPONSE);
assert!(
bytes
.windows(b"X-Telemt-Retry-After-Ms: 250\r\n".len())
.any(|window| window == b"X-Telemt-Retry-After-Ms: 250\r\n")
);
}
#[tokio::test]
@@ -191,6 +196,11 @@ mod tests {
let mut bytes = Vec::new();
client.read_to_end(&mut bytes).await.unwrap();
assert_eq!(bytes, super::SERVICE_UNAVAILABLE_RESPONSE);
assert!(
bytes
.windows(b"X-Telemt-Retry-After-Ms: 250\r\n".len())
.any(|window| window == b"X-Telemt-Retry-After-Ms: 250\r\n")
);
assert_eq!(
runtime
.telemetry()
+2
View File
@@ -297,6 +297,7 @@ impl Drop for UserIpPermit {
impl UserConnectionReservation {
/// Creates an active reservation after both admission counters were acquired.
#[cfg(test)]
pub(crate) fn new(
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
@@ -375,6 +376,7 @@ impl Drop for UserConnectionReservation {
}
/// Applies user quota, connection, and source-IP admission atomically.
#[cfg(test)]
pub(crate) async fn acquire_user_connection_reservation(
user: &str,
config: &ProxyConfig,
+3 -3
View File
@@ -71,9 +71,9 @@ mod authenticated;
use handshake_support::*;
#[cfg(test)]
pub use stream_entry::handle_client_stream;
pub use stream_entry::{
handle_client_stream_with_shared, handle_client_stream_with_shared_and_pool_runtime,
};
#[cfg(test)]
pub use stream_entry::handle_client_stream_with_shared;
pub use stream_entry::handle_client_stream_with_shared_and_pool_runtime;
pub struct ClientHandler;
+2 -1
View File
@@ -41,7 +41,8 @@ where
}
#[allow(clippy::too_many_arguments)]
#[allow(dead_code)]
/// Runs a client stream with shared process state in tests.
#[cfg(test)]
pub async fn handle_client_stream_with_shared<S>(
stream: S,
peer: SocketAddr,
+1
View File
@@ -269,6 +269,7 @@ impl DirectBufferBudget {
}
/// Returns the conservative ceiling used when memory discovery is unavailable.
#[cfg(test)]
pub(crate) fn fallback_direct_buffer_hard_limit() -> usize {
AUTO_HARD_FALLBACK_BYTES
}
+3 -3
View File
@@ -43,9 +43,9 @@ mod relay;
// Telegram DC resolution and upstream handshake.
mod routing;
pub(crate) use relay::{
handle_via_direct, handle_via_direct_with_shared, handle_via_direct_with_shared_and_conntrack,
};
#[cfg(test)]
pub(crate) use relay::handle_via_direct;
pub(crate) use relay::handle_via_direct_with_shared_and_conntrack;
use routing::*;
const UNKNOWN_DC_LOG_DISTINCT_LIMIT: usize = 1024;
static LOGGED_UNKNOWN_DCS: OnceLock<Mutex<HashSet<i16>>> = OnceLock::new();
+2 -1
View File
@@ -1,7 +1,7 @@
use super::*;
#[allow(dead_code)]
/// Runs Direct relay with standalone cancellation and shared-state defaults.
#[cfg(test)]
pub(crate) async fn handle_via_direct<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
@@ -39,6 +39,7 @@ where
}
/// Runs Direct relay for a kernel-backed TCP client tuple.
#[cfg(test)]
pub(crate) async fn handle_via_direct_with_shared<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
+4 -3
View File
@@ -77,15 +77,16 @@ mod relay;
pub use backend_setup::handle_bad_client;
use backend_setup::*;
use copy::*;
#[cfg(test)]
pub(crate) use handler::handle_bad_client_with_shared;
pub(in crate::proxy) use handler::handle_bad_client_with_shared_resolver;
use interfaces::*;
use relay::*;
use target::*;
#[cfg(test)]
use timing::mask_outcome_target_budget;
pub(crate) use timing::sample_lognormal_percentile_bounded;
use timing::{
mask_outcome_target_budget, wait_mask_connect_budget_if_needed, wait_mask_outcome_budget,
};
use timing::{wait_mask_connect_budget_if_needed, wait_mask_outcome_budget};
#[cfg(test)]
#[path = "tests/masking_security_tests.rs"]
+1
View File
@@ -1,6 +1,7 @@
use super::*;
/// Handles a bad client with shared pre-auth fallback admission state.
#[cfg(test)]
pub(crate) async fn handle_bad_client_with_shared<R, W>(
reader: R,
writer: W,
-1
View File
@@ -1,4 +1,3 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
+13 -1
View File
@@ -8,7 +8,9 @@ use std::time::Instant;
use dashmap::DashMap;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
use crate::proxy::direct_buffer_budget::{DirectBufferBudget, fallback_direct_buffer_hard_limit};
use crate::proxy::direct_buffer_budget::DirectBufferBudget;
#[cfg(test)]
use crate::proxy::direct_buffer_budget::fallback_direct_buffer_hard_limit;
use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState};
use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry};
use crate::proxy::traffic_limiter::TrafficLimiter;
@@ -103,6 +105,8 @@ pub(crate) struct ProxySharedState {
}
impl ProxySharedState {
/// Creates isolated process authorities for tests.
#[cfg(test)]
pub(crate) fn new() -> Arc<Self> {
Self::new_with_direct_buffer_budget(DirectBufferBudget::new(
fallback_direct_buffer_hard_limit(),
@@ -110,6 +114,7 @@ impl ProxySharedState {
}
/// Creates process state with the startup-resolved Direct buffer envelope.
#[cfg(test)]
pub(crate) fn new_with_direct_buffer_budget(
direct_buffer_budget: Arc<DirectBufferBudget>,
) -> Arc<Self> {
@@ -120,6 +125,7 @@ impl ProxySharedState {
}
/// Creates generation state around one process-owned user authority.
#[cfg(test)]
pub(crate) fn new_with_direct_buffer_budget_and_user_admission(
direct_buffer_budget: Arc<DirectBufferBudget>,
user_admission: Arc<UserAdmissionAuthority>,
@@ -203,6 +209,7 @@ impl ProxySharedState {
}
/// Reconciles the complete user authentication policy from configuration.
#[cfg(test)]
pub(crate) fn apply_user_config(
&self,
users: &HashMap<String, String>,
@@ -255,6 +262,7 @@ impl ProxySharedState {
}
/// Applies one persisted user mutation before asynchronous config reload.
#[cfg(test)]
pub(crate) fn stage_user(
&self,
user: &str,
@@ -299,6 +307,8 @@ impl ProxySharedState {
self.user_admission.claim_authenticated(user, credential_id)
}
/// Registers a test session without an authenticated credential.
#[cfg(test)]
pub(crate) fn register_user_session(
self: &Arc<Self>,
user: &str,
@@ -319,6 +329,8 @@ impl ProxySharedState {
Some(registration)
}
/// Cancels all owners of a user in lifecycle tests.
#[cfg(test)]
pub(crate) fn cancel_user_sessions(&self, user: &str) -> usize {
self.user_admission.cancel_user_owners(user)
}
+20
View File
@@ -96,8 +96,22 @@ pub(crate) struct UserMutationResult {
/// Incarnation invalidated or created by the mutation.
pub(crate) incarnation: UserIncarnation,
/// Number of live owners cancelled by the mutation.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Mutation results are asserted by admission regression tests"
)
)]
pub(crate) cancelled: usize,
/// Whether the effective enabled state changed from enabled to disabled.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Mutation results are asserted by admission regression tests"
)
)]
pub(crate) newly_disabled: bool,
}
@@ -109,6 +123,7 @@ pub(crate) struct UserAdmissionAuthority {
impl UserAdmissionAuthority {
/// Creates an uninitialized authority for isolated tests and startup wiring.
#[cfg(test)]
pub(crate) fn new() -> Arc<Self> {
Self::new_with_quota_store(Arc::new(QuotaStore::default()))
}
@@ -173,6 +188,7 @@ impl UserAdmissionAuthority {
}
/// Reconciles the complete configured user set into the process authority.
#[cfg(test)]
pub(crate) fn apply_config(
&self,
users: &HashMap<String, String>,
@@ -204,6 +220,7 @@ impl UserAdmissionAuthority {
}
/// Returns the number of rejected updates from non-owning generations.
#[cfg(test)]
pub(crate) fn stale_config_source_rejections(&self) -> u64 {
self.state.lock().stale_config_source_rejections
}
@@ -338,6 +355,7 @@ impl UserAdmissionAuthority {
}
/// Applies one persisted user value ahead of asynchronous runtime reload.
#[cfg(test)]
pub(crate) fn stage_user(
&self,
user: &str,
@@ -502,6 +520,7 @@ impl UserAdmissionAuthority {
}
/// Registers a legacy owner when no credential snapshot is available.
#[cfg(test)]
pub(crate) fn register_legacy(self: &Arc<Self>, user: &str) -> Option<UserSessionRegistration> {
let credential_id = {
let state = self.state.lock();
@@ -518,6 +537,7 @@ impl UserAdmissionAuthority {
}
/// Cancels all current owners without changing admission policy.
#[cfg(test)]
pub(crate) fn cancel_user_owners(&self, user: &str) -> usize {
let tokens = self.state.lock().owner_tokens(user);
let count = tokens.len();
+2 -10
View File
@@ -63,6 +63,7 @@ impl QuotaStateOwner {
}
/// Loads only quota entries owned by currently configured users.
#[cfg(test)]
pub(crate) async fn load(&self, configured_users: &BTreeSet<String>) {
let _ = self.load_inner(configured_users, false).await;
}
@@ -179,16 +180,6 @@ impl QuotaStateOwner {
wait_for_blocking_io(task).await
}
/// Removes a deleted user's persisted quota checkpoint.
pub(crate) async fn remove_user(
&self,
configured_users: &BTreeSet<String>,
user: &str,
) -> std::io::Result<()> {
let guard = Arc::clone(&self.mutation).lock_owned().await;
self.remove_user_locked(configured_users, user, guard).await
}
/// Persists deletion while retaining the already-owned lifecycle gate.
pub(crate) async fn remove_user_locked(
&self,
@@ -323,6 +314,7 @@ async fn read_state_file(path: &Path) -> std::io::Result<Option<QuotaStateFile>>
Ok(Some(state))
}
#[cfg(test)]
async fn write_state_file(path: &Path, state: QuotaStateFile) -> std::io::Result<()> {
let path = path.to_path_buf();
let task = tokio::task::spawn_blocking(move || write_state_file_blocking(&path, &state));
+1
View File
@@ -61,6 +61,7 @@ impl SlotBudget {
}
/// Returns the exact number of currently committed or reserved slots.
#[cfg(test)]
pub(crate) fn used(&self) -> usize {
self.used.load(Ordering::Acquire)
}
+12 -27
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use rand::RngExt;
#[cfg(test)]
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::{debug, info, warn};
@@ -65,34 +66,23 @@ struct FamilyFloorPlan {
warm_cap_effective_total: usize,
active_writers_current: usize,
warm_writers_current: usize,
target_writers_total: usize,
}
#[derive(Debug)]
struct FamilyReconnectOutcome {
key: (i32, IpFamily),
dc: i32,
family: IpFamily,
required: usize,
endpoint_count: usize,
}
#[cfg(test)]
struct ScheduledReconnects<'a> {
inflight: &'a mut HashMap<(i32, IpFamily), usize>,
keys: Vec<(i32, IpFamily)>,
}
#[cfg(test)]
impl ScheduledReconnects<'_> {
fn current(&self, key: &(i32, IpFamily)) -> usize {
self.inflight.get(key).copied().unwrap_or(0)
}
fn reserve(&mut self, key: (i32, IpFamily)) {
self.keys.push(key);
*self.inflight.entry(key).or_insert(0) += 1;
}
}
#[cfg(test)]
impl Drop for ScheduledReconnects<'_> {
fn drop(&mut self) {
for key in self.keys.drain(..) {
@@ -118,29 +108,24 @@ mod scheduler;
mod drain;
// Per-family reconnect scheduling and health state.
mod family;
// Adaptive-floor planning and warning rate limits.
// Adaptive-floor planning.
mod floor_plan;
// Idle-writer refresh and cap-aware swaps.
// Idle-writer refresh under the current capacity policy.
mod idle_refresh;
// Single-endpoint recovery and shadow rotation.
mod recovery;
// Independent stale draining-writer watchdog.
mod zombie_watchdog;
#[cfg(test)]
pub(in crate::transport::middle_proxy) use drain::health_drain_close_budget;
pub use drain::me_drain_timeout_enforcer;
pub(in crate::transport::middle_proxy) use drain::{
health_drain_close_budget, reap_draining_writers,
};
pub(in crate::transport::middle_proxy) use drain::reap_draining_writers;
use family::{check_family, update_family_runtime_state};
use floor_plan::{
build_family_floor_plan, live_active_writers_for_dc_family, should_emit_rate_limited_warn,
};
use idle_refresh::{maybe_refresh_idle_writer_for_dc, maybe_swap_idle_writer_for_cap};
use floor_plan::build_family_floor_plan;
use idle_refresh::maybe_refresh_idle_writer_for_dc;
pub use monitor::me_health_monitor;
use recovery::{
has_bound_clients_on_endpoint, maybe_rotate_single_endpoint_shadow,
recover_single_endpoint_outage,
};
use recovery::{has_bound_clients_on_endpoint, maybe_rotate_single_endpoint_shadow};
pub use zombie_watchdog::me_zombie_writer_watchdog;
#[cfg(test)]
@@ -1,57 +1,5 @@
use super::*;
pub(super) fn should_emit_rate_limited_warn(
next_allowed: &mut HashMap<(i32, IpFamily), Instant>,
key: (i32, IpFamily),
now: Instant,
cooldown: Duration,
) -> bool {
let Some(ready_at) = next_allowed.get(&key).copied() else {
next_allowed.insert(key, now + cooldown);
return true;
};
if now >= ready_at {
next_allowed.insert(key, now + cooldown);
return true;
}
false
}
pub(super) async fn live_active_writers_for_dc_family(
pool: &Arc<MePool>,
dc: i32,
family: IpFamily,
) -> usize {
let active_generation = pool.current_generation();
let writers = pool.writers.read().await;
writers
.iter()
.filter(|writer| {
if writer.draining.load(std::sync::atomic::Ordering::Relaxed) {
return false;
}
if writer.writer_dc != dc {
return false;
}
if writer.generation != active_generation {
return false;
}
if !matches!(
crate::transport::middle_proxy::pool::WriterContour::from_u8(
writer.contour.load(std::sync::atomic::Ordering::Relaxed),
),
crate::transport::middle_proxy::pool::WriterContour::Active
) {
return false;
}
match family {
IpFamily::V4 => writer.addr.is_ipv4(),
IpFamily::V6 => writer.addr.is_ipv6(),
}
})
.count()
}
pub(super) fn adaptive_floor_class_min(
pool: &Arc<MePool>,
endpoint_count: usize,
@@ -177,7 +125,6 @@ pub(super) async fn build_family_floor_plan(
warm_cap_effective_total: warm_cap_configured_total,
active_writers_current,
warm_writers_current,
target_writers_total: 0,
};
}
@@ -199,7 +146,6 @@ pub(super) async fn build_family_floor_plan(
warm_cap_effective_total: warm_cap_configured_total,
active_writers_current,
warm_writers_current,
target_writers_total: target_total,
};
}
@@ -251,7 +197,6 @@ pub(super) async fn build_family_floor_plan(
}
let active_cap_effective_total =
active_cap_configured_total.max(other_active.saturating_add(min_sum));
let target_writers_total = other_active.saturating_add(target_sum);
FamilyFloorPlan {
by_dc,
active_cap_configured_total,
@@ -260,6 +205,5 @@ pub(super) async fn build_family_floor_plan(
warm_cap_effective_total: warm_cap_configured_total,
active_writers_current,
warm_writers_current,
target_writers_total,
}
}
@@ -1,147 +1,5 @@
use super::*;
pub(super) async fn maybe_swap_idle_writer_for_cap(
pool: &Arc<MePool>,
rng: &Arc<SecureRandom>,
dc: i32,
family: IpFamily,
endpoints: &[SocketAddr],
required: usize,
floor_targets_by_dc: &HashMap<i32, usize>,
live_writer_ids_by_addr: &HashMap<(i32, SocketAddr), Vec<u64>>,
writer_idle_since: &HashMap<u64, u64>,
bound_clients_by_writer: &HashMap<u64, usize>,
) -> bool {
let Some(replacement_endpoint) = endpoints
.iter()
.min_by_key(|endpoint| {
live_writer_ids_by_addr
.get(&(dc, **endpoint))
.map_or(0, Vec::len)
})
.copied()
else {
return false;
};
let mut alive_by_dc = HashMap::<i32, usize>::new();
for ((writer_dc, endpoint), writer_ids) in live_writer_ids_by_addr {
if endpoint.is_ipv4() == matches!(family, IpFamily::V4) {
*alive_by_dc.entry(*writer_dc).or_insert(0) += writer_ids.len();
}
}
let now_epoch_secs = MePool::now_epoch_secs();
let mut candidates = Vec::<(u64, SocketAddr, i32, u64, usize)>::new();
for ((writer_dc, endpoint), writer_ids) in live_writer_ids_by_addr {
if endpoint.is_ipv4() != matches!(family, IpFamily::V4) {
continue;
}
let Some(donor_floor) = floor_targets_by_dc.get(writer_dc).copied() else {
continue;
};
if alive_by_dc.get(writer_dc).copied().unwrap_or(0) <= donor_floor {
continue;
}
for writer_id in writer_ids {
if bound_clients_by_writer.get(writer_id).copied().unwrap_or(0) > 0 {
continue;
}
let Some(idle_since_epoch_secs) = writer_idle_since.get(writer_id).copied() else {
continue;
};
candidates.push((
*writer_id,
*endpoint,
*writer_dc,
now_epoch_secs.saturating_sub(idle_since_epoch_secs),
donor_floor,
));
}
}
candidates.sort_unstable_by(|left, right| right.3.cmp(&left.3));
for (old_writer_id, donor_endpoint, donor_dc, idle_age_secs, donor_floor) in candidates {
let expected_role = {
let writers = pool.writers.read().await;
writers
.iter()
.find(|writer| writer.id == old_writer_id)
.map(WriterRole::from_writer)
};
let Some(expected_role) = expected_role else {
continue;
};
if expected_role.dc != donor_dc
|| expected_role.family != family
|| expected_role.contour != WriterContour::Active
{
continue;
}
let Some(mut reservation) = pool
.registry
.try_reserve_writer_replacement(old_writer_id)
.await
else {
continue;
};
let replace = pool.replace_writer_with_generation_contour_for_dc(
replacement_endpoint,
rng.as_ref(),
pool.current_generation(),
WriterContour::Active,
dc,
expected_role,
WriterReplacementPurpose::FloorRebalance {
donor_floor,
receiver_floor: required,
},
&mut reservation,
);
match tokio::time::timeout(pool.reconnect_runtime.me_one_timeout, replace).await {
Ok(Ok(())) => {
info!(
dc = %dc,
?family,
%replacement_endpoint,
donor_dc = %donor_dc,
%donor_endpoint,
old_writer_id,
idle_age_secs,
"Adaptive floor cap rebalance committed"
);
return true;
}
Ok(Err(error)) => {
debug!(
dc = %dc,
?family,
%replacement_endpoint,
donor_dc = %donor_dc,
old_writer_id,
idle_age_secs,
%error,
"Adaptive floor cap rebalance failed"
);
return false;
}
Err(_) => {
debug!(
dc = %dc,
?family,
%replacement_endpoint,
donor_dc = %donor_dc,
old_writer_id,
idle_age_secs,
"Adaptive floor cap rebalance timed out"
);
return false;
}
}
}
false
}
pub(super) async fn maybe_refresh_idle_writer_for_dc(
pool: &Arc<MePool>,
rng: &Arc<SecureRandom>,
@@ -15,6 +15,7 @@ pub(super) fn has_bound_clients_on_endpoint(
}
/// Restores missing single-endpoint coverage under the bounded outage retry policy.
#[cfg(test)]
pub(super) async fn recover_single_endpoint_outage(
pool: &Arc<MePool>,
rng: &Arc<SecureRandom>,
+1
View File
@@ -169,6 +169,7 @@ impl MePool {
}
/// Applies the configured or HTTP-detected NAT IP to non-public addresses.
#[cfg(test)]
pub(super) fn translate_ip_for_nat(&self, ip: IpAddr) -> IpAddr {
Self::translate_ip_with_nat(
ip,
-17
View File
@@ -193,23 +193,6 @@ impl MePool {
self.refill_states.lock().contains_key(&key)
}
pub(super) async fn connect_endpoints_round_robin(
self: &Arc<Self>,
dc: i32,
endpoints: &[SocketAddr],
rng: &SecureRandom,
) -> bool {
self.connect_endpoints_round_robin_with_generation_contour(
dc,
endpoints,
rng,
self.current_generation(),
WriterContour::Active,
WriterOpenIntent::Normal,
)
.await
}
pub(super) async fn connect_endpoints_round_robin_with_generation_contour(
self: &Arc<Self>,
dc: i32,
@@ -378,6 +378,7 @@ impl MePool {
}
/// Returns the currently authoritative endpoint set for drain and coverage decisions.
#[cfg(test)]
pub(in crate::transport::middle_proxy) async fn desired_dc_endpoints(
&self,
) -> HashMap<i32, HashSet<SocketAddr>> {
@@ -169,15 +169,6 @@ impl MePool {
}
}
/// Reconciles one immutable endpoint snapshot without draining uncovered groups.
pub async fn zero_downtime_reinit_after_map_change(
self: &Arc<Self>,
rng: &SecureRandom,
) -> bool {
let endpoint_snapshot = self.endpoint_snapshot.load_full();
self.reinit_endpoint_snapshot(rng, endpoint_snapshot).await
}
/// Reconciles exactly the revision owned by the scheduler's retry objective.
pub(in crate::transport::middle_proxy) async fn reinit_endpoint_snapshot(
self: &Arc<Self>,
@@ -416,9 +407,4 @@ impl MePool {
}
true
}
/// Applies periodic rotation using the same coverage and generation fences as map changes.
pub async fn zero_downtime_reinit_periodic(self: &Arc<Self>, rng: &SecureRandom) -> bool {
self.zero_downtime_reinit_after_map_change(rng).await
}
}
@@ -55,6 +55,13 @@ pub(super) enum WriterReplacementPurpose {
/// Replaces an idle writer before the upstream idle lifetime expires.
IdleRefresh,
/// Moves one idle slot from a donor above floor to a receiver below floor.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Legacy purpose is exercised by commit-fence regression tests"
)
)]
FloorRebalance {
/// Minimum writer count that must remain in the donor group.
donor_floor: usize,
@@ -152,6 +152,7 @@ impl MePool {
/// Commits writer visibility and lifecycle ownership after all cancellation points.
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(in crate::transport::middle_proxy) async fn publish_prepared_writer<F>(
self: &Arc<Self>,
writer: MeWriter,
@@ -407,38 +407,6 @@ impl MePool {
removed
}
pub(crate) async fn mark_writer_draining_with_timeout(
self: &Arc<Self>,
writer_id: u64,
timeout: Option<Duration>,
allow_drain_fallback: bool,
) {
let timeout = timeout.filter(|d| !d.is_zero());
let found = {
let ws = self.writers.write().await;
let mut registry_registration = self.registry.prepare_writer_registration().await;
if let Some(w) = ws.iter().find(|w| w.id == writer_id) {
if !allow_drain_fallback {
registry_registration.retire(writer_id);
}
self.apply_writer_draining_state(w, timeout, allow_drain_fallback);
true
} else {
false
}
};
if !found {
return;
}
let timeout_secs = timeout.map(|d| d.as_secs()).unwrap_or(0);
debug!(
writer_id,
timeout_secs, allow_drain_fallback, "ME writer marked draining"
);
}
/// Publishes drain metadata before the release-store that makes draining observable.
pub(in crate::transport::middle_proxy) fn apply_writer_draining_state(
&self,
@@ -472,11 +440,6 @@ impl MePool {
!already_draining
}
pub(crate) async fn mark_writer_draining(self: &Arc<Self>, writer_id: u64) {
self.mark_writer_draining_with_timeout(writer_id, Some(Duration::from_secs(300)), false)
.await;
}
pub(in crate::transport::middle_proxy) fn writer_accepts_new_binding(
&self,
writer: &MeWriter,
+4
View File
@@ -180,6 +180,8 @@ impl ConnRegistry {
.as_secs()
}
/// Creates a test registry with the requested route channel capacity.
#[cfg(test)]
pub fn with_route_channel_capacity(route_channel_capacity: usize) -> Self {
let route_channel_capacity = route_channel_capacity.max(1);
Self::with_route_limits(
@@ -288,6 +290,8 @@ impl ConnRegistry {
.store(watermark, Ordering::Relaxed);
}
/// Registers a test route without a lifetime cleanup lease.
#[cfg(test)]
pub async fn register(&self) -> (u64, mpsc::Receiver<MeResponse>) {
self.register_route()
}
@@ -348,6 +348,8 @@ impl ConnRegistry {
WriterBindOutcome::Bound
}
/// Binds a test route and reports whether publication succeeded.
#[cfg(test)]
pub async fn bind_writer(&self, conn_id: u64, writer_id: u64, meta: ConnMeta) -> bool {
matches!(
self.bind_writer_with_outcome(conn_id, writer_id, meta)
+2
View File
@@ -48,6 +48,8 @@ impl UpstreamManager {
Ok(self)
}
/// Replaces DNS overrides for isolated upstream tests.
#[cfg(test)]
pub(crate) fn update_dns_overrides(&self, entries: &[String]) -> Result<()> {
self.dns_resolver.apply_entries(entries)
}
+4 -2
View File
@@ -11,9 +11,11 @@ pub(crate) use path::{
AnchoredPath, chdir_nofollow_or_create, open_compatible_dir, open_dir_nofollow,
open_trusted_dir_nofollow_or_create,
};
#[cfg(test)]
pub(crate) use write::open_append_regular;
pub(crate) use write::{
atomic_replace, atomic_replace_async, open_append_regular, open_append_regular_at,
read_regular_limited, read_regular_limited_async,
atomic_replace, atomic_replace_async, open_append_regular_at, read_regular_limited,
read_regular_limited_async,
};
#[cfg(test)]
+1
View File
@@ -91,6 +91,7 @@ fn same_file_version(left: &std::fs::Metadata, right: &std::fs::Metadata) -> boo
}
/// Opens an append-only regular file without following path components or hard links.
#[cfg(test)]
pub(crate) fn open_append_regular(path: &Path, mode: u32) -> io::Result<std::fs::File> {
let anchored = AnchoredPath::open_creating_parents(path, 0o750)?;
open_regular_at(
+2
View File
@@ -38,6 +38,7 @@ pub(crate) fn render(
carrier_probe_coalesce_ms: u64,
bridge_diagnostics_enabled: bool,
carrier_method: WebCarrierMethod,
retry_jitter: &crate::config::WebRetryJitterConfig,
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
@@ -47,6 +48,7 @@ pub(crate) fn render(
let body = template(bridge_diagnostics_enabled)
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
.replace("__RETRY_JITTER__", &retry_jitter.bridge_policy())
.replace("__BASE_PREFIX__", base_prefix)
.replace("__BOOTSTRAP__", bootstrap)
.replace("__CARRIER_METHOD__", carrier_method.as_str())
+43 -1
View File
@@ -11,7 +11,7 @@ function renderedPage(){
page=page.replace('__DIAGNOSTIC_RUNTIME__','');
for(const [key,file] of Object.entries(modules))if(page.includes('__'+key+'_RUNTIME__'))page=page.replace('__'+key+'_RUNTIME__',()=>fs.readFileSync(path.join(__dirname,file+'.js'),'utf8'));
page=page.replace('__RUNTIME__',()=>fs.readFileSync(path.join(__dirname,'runtime.js'),'utf8'));
const values={BOOTSTRAP:'A'.repeat(43),HOST:'proxy.example.com',BASE_PREFIX:'',CARRIER_METHOD:'POST',NEGOTIATION_ENABLED:'true',CANDIDATE_COUNT:4,CARRIER_DEADLINES:'3,5,8,12',LONG_POLL_SECS:25,BRIDGE_REQUEST_SECS:10,BRIDGE_RETRY_SECS:90,BRIDGE_RECOVERY_SECS:15,WEBSOCKET_OPEN_SECS:15,RECONNECT_GRACE_SECS:120,CARRIER_PROBE_COALESCE_MS:0,BATCH_LIMIT:2097152,QUEUE_LIMIT:33554432,QUEUE_ITEMS:16384,MAX_STREAMS:1024,STATUS_FUNCTION:"state=>{if(port&&!closed)port.postMessage({t:'status',state})}",HELLO_TIMEOUT_CALLBACK:"()=>fail('timeout')",PAGEHIDE_CALLBACK:'()=>close(true)'};
const values={RETRY_JITTER:'{enabled:false,min:0,max:250,peak:125,triangular:false}',BOOTSTRAP:'A'.repeat(43),HOST:'proxy.example.com',BASE_PREFIX:'',CARRIER_METHOD:'POST',NEGOTIATION_ENABLED:'true',CANDIDATE_COUNT:4,CARRIER_DEADLINES:'3,5,8,12',LONG_POLL_SECS:25,BRIDGE_REQUEST_SECS:10,BRIDGE_RETRY_SECS:90,BRIDGE_RECOVERY_SECS:15,WEBSOCKET_OPEN_SECS:15,RECONNECT_GRACE_SECS:120,CARRIER_PROBE_COALESCE_MS:0,BATCH_LIMIT:2097152,QUEUE_LIMIT:33554432,QUEUE_ITEMS:16384,MAX_STREAMS:1024,STATUS_FUNCTION:"state=>{if(port&&!closed)port.postMessage({t:'status',state})}",HELLO_TIMEOUT_CALLBACK:"()=>fail('timeout')",PAGEHIDE_CALLBACK:'()=>close(true)'};
return page.replace(/__([A-Z_]+)__/g,(all,key)=>key.startsWith('DIAGNOSTIC_')?'':String(values[key]??''));
}
function frame(type,id=0,payload=[]){
@@ -54,6 +54,48 @@ function streaming(env,request,next='1',length=null){
let controller;const body=new ReadableStream({start(value){controller=value}}),headers={'X-Down-Cursor':next};if(length!==null)headers['Content-Length']=String(length);
request.answered=true;request.resolve({status:200,headers:new Headers(headers),body});return controller;
}
test('Telemt overload hint retries at 250 ms with legacy and malformed fallback',async page=>{
for(const android of [false,true])for(const carrier of ['https','https-lanes'])for(const [status,hint,retry,delay] of [
[503,'250','1',250],[503,null,'1',1000],[503,'0','1',1000],
[503,'0.25','1',1000],[503,'250, 250','1',1000],[503,'250','2',2000],[429,'250','1',1000]
]){
const env=await session(page,carrier,4,android);env.send(frame(1,1));await flush();
const original=env.pending('/api/v1/up')[0],headers={'Retry-After':retry};
if(hint!==null)headers['X-Telemt-Retry-After-Ms']=hint;
env.answer(original,status,null,headers);await flush();
await env.tick(delay-1);assert.equal(env.pending('/api/v1/up').length,0,'retry must not run early');
await env.tick(1);const replay=env.pending('/api/v1/up')[0];assert.ok(replay,'retry at selected deadline');
assert.equal(replay.options.headers['X-Up-Seq'],original.options.headers['X-Up-Seq']);
assert.deepEqual(new Uint8Array(replay.options.body),new Uint8Array(original.options.body));
ack(env,replay);await flush();env.close();await flush();
}
});
test('repeated 250 ms overload hints retain the nine-attempt limit',async page=>{
const env=await session(page);env.send(frame(1,1));await flush();
for(let attempt=1;attempt<=9;attempt++){
const request=env.pending('/api/v1/up')[0];assert.ok(request,'bounded retry attempt '+attempt);
env.answer(request,503,null,{'Retry-After':'1','X-Telemt-Retry-After-Ms':'250'});await flush();
if(attempt<9)await env.tick(250);
}
await env.tick(250);
assert.equal(env.requests.filter(r=>r.url.endsWith('/api/v1/up')).length,9);
env.close();await flush();
});
test('closing the bridge cancels the 250 ms overload retry',async page=>{
const env=await session(page);env.send(frame(1,1));await flush();
env.answer(env.pending('/api/v1/up')[0],503,null,{'Retry-After':'1','X-Telemt-Retry-After-Ms':'250'});await flush();
await env.tick(249);env.close();await flush();await env.tick(1000);
assert.equal(env.requests.filter(r=>r.url.endsWith('/api/v1/up')).length,1);
});
test('retry jitter distributions, fallback, deadlines and page ownership',require('./retry_jitter_tests'));
test('configured jitter is added once to the Telemt delay',async page=>{
page=page.replace('{enabled:false,min:0,max:250,peak:125,triangular:false}','{enabled:true,min:100,max:100,peak:100,triangular:false}');
const env=await session(page);env.send(frame(1,1));await flush();
env.answer(env.pending('/api/v1/up')[0],503,null,{'Retry-After':'1','X-Telemt-Retry-After-Ms':'250'});await flush();
await env.tick(349);assert.equal(env.pending('/api/v1/up').length,0);
await env.tick(1);assert.equal(env.pending('/api/v1/up').length,1);
env.close();await flush();
});
test('already queued OPEN and DATA share the zero-wait startup request',async page=>{
const env=await session(page);const body=join(frame(1,1),frame(2,1,[7,8]));env.send(body);await flush();
assert.equal(env.pending('/api/v1/up')[0].options.body.byteLength,body.byteLength);env.close();await flush();
+1
View File
@@ -4,6 +4,7 @@ use std::process::{Command, Stdio};
/// Exercises the actual generated page with deterministic network and native-boundary doubles.
pub(super) fn run(page: &super::BridgePage) {
let mut child = Command::new("node")
.current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src/web/bridge"))
.args(["-e", include_str!("behavior_tests.js"), "--", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
+2
View File
@@ -23,6 +23,7 @@ pub(super) fn render(
carrier_probe_coalesce_ms: u64,
bridge_diagnostics_enabled: bool,
carrier_method: WebCarrierMethod,
retry_jitter: &crate::config::WebRetryJitterConfig,
nonce: &str,
) -> BridgePage {
let diagnostic_script = if bridge_diagnostics_enabled {
@@ -110,6 +111,7 @@ pub(super) fn render(
)
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
.replace("__RETRY_JITTER__", &retry_jitter.bridge_policy())
.replace("__BASE_PREFIX__", base_prefix)
.replace("__BOOTSTRAP__", bootstrap)
.replace("__CARRIER_METHOD__", carrier_method.as_str())
+26
View File
@@ -55,11 +55,13 @@ struct Case {
negotiation: bool,
candidates: usize,
value: usize,
jitter: crate::config::WebRetryJitterConfig,
}
impl Case {
fn new(index: usize) -> Self {
Self {
jitter: crate::config::WebRetryJitterConfig::default(),
host: format!("proxy-{index}.example.com"),
base: "/telegram/web/".to_string(),
bootstrap: format!("{index:043}"),
@@ -96,6 +98,7 @@ impl Case {
self.value as u64 - 1,
self.diagnostics,
self.method,
&self.jitter,
rng,
)
}
@@ -121,6 +124,7 @@ impl Case {
self.value as u64 - 1,
self.diagnostics,
self.method,
&self.jitter,
nonce,
)
}
@@ -223,3 +227,25 @@ fn concurrent_responses_keep_nonces_and_configuration_response_owned() {
assert!(template(diagnostics).contains("__BASE_PREFIX__"));
}
}
#[test]
fn retry_jitter_is_rendered_per_page_without_changing_existing_pages() {
let rng = SecureRandom::new();
let mut case = Case::new(1);
let old = case.render(&rng);
case.jitter.enabled = true;
case.jitter.min_ms = 10;
case.jitter.max_ms = 301;
case.jitter.distribution = crate::config::WebRetryJitterDistribution::Triangular;
let new = case.render(&rng);
assert!(
old.body
.contains("{enabled:false,min:0,max:250,peak:125,triangular:false}")
);
assert!(
new.body
.contains("{enabled:true,min:10,max:301,peak:155.5,triangular:true}")
);
let nonce = case.assert_page(&new);
assert_eq!(new.body, case.reference(&nonce).body);
}
+23 -3
View File
@@ -1,5 +1,21 @@
(()=>{'use strict';
function create(settings){
// Snapshot policy per page, including during session recovery.
const jitter=Object.freeze(Object.assign({},settings.jitter||{enabled:false}));
const randomWord=jitter.enabled?new Uint32Array(1):null;
function randomUnit(){
try{if(globalThis.crypto&&typeof globalThis.crypto.getRandomValues==='function'){
globalThis.crypto.getRandomValues(randomWord);return randomWord[0]/4294967296;
}}catch(error){}
return Math.random();
}
function jitterMs(){
const a=jitter.min,b=jitter.max;if(a===b)return a;
const u=randomUnit();
if(!jitter.triangular)return a+Math.floor(u*(b-a+1));
const c=jitter.peak,span=b-a;
return Math.round(u<(c-a)/span?a+Math.sqrt(u*span*(c-a)):b-Math.sqrt((1-u)*span*(b-c)));
}
const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{
if(signal&&signal.aborted){reject(new Error('request aborted'));return}
const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()}
@@ -12,11 +28,15 @@ function create(settings){
});
function retryAfterMs(response){
const header=response.headers.get('Retry-After');
// Only the exact Telemt overload hint overrides its legacy whole-second fallback.
if(response.status===503&&header==='1'&&response.headers.get('X-Telemt-Retry-After-Ms')==='250')return 250;
if(!header)return 0;
// Enabled policies must never shorten a valid server minimum to the legacy 30 s cap.
if(jitter.enabled&&/^\d+$/.test(header))return Number(header)*1000;
const seconds=Number(header);
if(Number.isFinite(seconds)&&seconds>=0)return Math.min(seconds*1000,30000);
if(Number.isFinite(seconds)&&seconds>=0)return jitter.enabled?seconds*1000:Math.min(seconds*1000,30000);
const when=Date.parse(header);
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?(jitter.enabled?delta:Math.min(delta,30000)):0;}
return 0;
}
function retryableStatus(status){return status===408||status===429||status===502||status===503||status===504}
@@ -63,7 +83,7 @@ function create(settings){
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
const after=Math.min(deadline-Date.now(),remainingBudget?remainingBudget():Infinity);if(attempt>=attempts()||after<=0)break;
settings.retrying();
const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
const backoff=jitter.enabled?(wait||delay)+jitterMs():wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,2000);
}
throw settings.failure(lastReason,'carrier retry limit reached');
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const path=require('node:path');
const source=fs.readFileSync(path.join(__dirname,'request.js'),'utf8');
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()};
function harness(policy,unit=0.5,cryptoMode='ok',budget=90000){
let now=0,next=1,calls=0,draws=0;const timers=new Map();
const context={Uint32Array,AbortController,Date:class extends Date{static now(){return now}},
Math:Object.assign(Object.create(Math),{random:()=>unit}),
setTimeout:(fn,ms)=>{const id=next++;timers.set(id,{fn,ms});return id},clearTimeout:id=>timers.delete(id),
fetch:async()=>{calls++;return {status:503,headers:new Headers({'Retry-After':'1','X-Telemt-Retry-After-Ms':'250'})}}
};
if(cryptoMode!=='absent')context.crypto={getRandomValues(word){draws++;if(cryptoMode==='throw')throw Error('unavailable');word[0]=Math.floor(unit*4294967296);return word}};
vm.createContext(context);vm.runInContext(source,context);
const settings={jitter:policy,base:()=>'',closed:()=>false,retryMs:()=>budget,longPollMs:()=>0,requestMs:()=>10000,
batchLimit:()=>8,cancel(){},retrying(){},failure:(_,message)=>new Error(message),reason:()=>'',read:async()=>new ArrayBuffer(0)};
const client=context.TelemtBridgeRequest.create(settings);
return {context,timers,client,get calls(){return calls},get draws(){return draws},
async sample(status=503,headers={'Retry-After':'1','X-Telemt-Retry-After-Ms':'250'}){
context.fetch=async()=>{calls++;if(status===0)throw Error('network');return {status,headers:new Headers(headers)}};
const controller=new AbortController();const promise=client.send('/api/v1/up',{signal:controller.signal},null).catch(()=>{});
await flush();const timer=[...timers.values()][0];assert.ok(timer);const delay=timer.ms;
controller.abort();await promise;assert.equal(timers.size,0);return delay;
},async expire(){const item=[...timers.entries()][0];assert.ok(item);timers.delete(item[0]);now+=item[1].ms;item[1].fn();await flush()}
};
}
module.exports=async()=>{
const config={enabled:true,min:0,max:250,peak:125,triangular:false};
for(const mode of ['ok','absent','throw']){
const h=harness(config,0.5,mode);assert.equal(await h.sample(),375);
}
for(const triangular of [false,true]){
for(const peak of [0,25,100]){
let sum=0;
for(let i=0;i<256;i++){
const h=harness({...config,min:0,max:100,peak,triangular},(i+0.5)/256);
const value=await h.sample()-250;assert.ok(value>=0&&value<=100);sum+=value;
}
assert.ok(Math.abs(sum/256-(triangular?(100+peak)/3:50))<1);
}
}
for(const u of [0,0.25,0.5,0.75,1-1/4294967296]){
const h=harness({...config,min:0,max:100,peak:50,triangular:true},u);
const expected=[0,35,50,65,100][[0,0.25,0.5,0.75,1-1/4294967296].indexOf(u)];
assert.equal(await h.sample()-250,expected);
}
const frozen={...config,min:100,max:100};const h=harness(frozen);frozen.min=0;frozen.max=0;
assert.equal(await h.sample(),350);assert.equal(h.draws,0);
for(const status of [0,408,429,502,503,504])assert.equal(await h.sample(status,{}),350);
assert.equal(await h.sample(429,{'Retry-After':'2'}),2100);
assert.equal(await h.sample(503,{'Retry-After':'40'}),40100);
assert.equal(await h.sample(503,{'Retry-After':new Date(40000).toUTCString()}),40100);
assert.equal(await h.sample(503,{'Retry-After':'bad'}),350);
// Budget exhaustion must not turn a long minimum into an early retry.
const bounded=harness(config,0.5,'ok',1000);
bounded.context.fetch=async()=>({status:503,headers:new Headers({'Retry-After':'99999999999999999999999999999999999999999'})});
const pending=bounded.client.send('/api/v1/up',{},null).then(()=>assert.fail('unexpected success'),()=>{});
await flush();await bounded.expire();await pending;assert.equal(bounded.timers.size,0);
// A new draw is made per retry while the exponential base and attempt cap remain.
const retries=harness(config,0.5);let attempts=0;
retries.context.fetch=async()=>{attempts++;throw Error('network')};
const failed=retries.client.send('/api/v1/up',{},null).catch(()=>{});await flush();
for(const delay of [375,625,1125,2125,2125,2125,2125,2125]){
assert.equal([...retries.timers.values()][0].ms,delay);await retries.expire();
}
await failed;assert.equal(attempts,9);assert.equal(retries.draws,8);assert.equal(retries.timers.size,0);
// Successful responses do not draw entropy or wait for jitter.
const success=harness(config);success.context.fetch=async()=>({status:204,headers:new Headers()});
await success.client.send('/api/v1/up',{},null);assert.equal(success.draws,0);assert.equal(success.timers.size,0);
};
+1
View File
@@ -30,6 +30,7 @@ const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.te
const status=__STATUS_FUNCTION__;
const socketURL=()=>relayBase.replace(/^https:/,'wss:')+'/api/v1/ws';
const requestClient=requestSupport.create({
jitter:Object.freeze(__RETRY_JITTER__),
base:()=>relayBase,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,
failure,reason:failureReason,retrying:()=>status('reconnecting')
+6
View File
@@ -23,6 +23,7 @@ fn carrier_method_is_page_owned_and_used_by_every_https_request() {
0,
true,
method,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
);
assert!(!page.body.contains("__"));
@@ -80,6 +81,7 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
0,
false,
WebCarrierMethod::Post,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
)
}
@@ -105,6 +107,7 @@ fn render_diagnostic_page(bootstrap: &str) -> BridgePage {
0,
true,
WebCarrierMethod::Post,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
)
}
@@ -157,6 +160,7 @@ fn rendered_page_resolves_carriers_against_the_exact_base_path() {
0,
true,
WebCarrierMethod::Post,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
);
@@ -211,6 +215,7 @@ fn rendered_page_embeds_the_configured_bridge_timing_policy() {
4,
false,
WebCarrierMethod::Post,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
);
@@ -265,6 +270,7 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
0,
false,
WebCarrierMethod::Post,
&crate::config::WebRetryJitterConfig::default(),
&SecureRandom::new(),
);
assert!(page.body.contains(
+1
View File
@@ -432,6 +432,7 @@ async fn handle_root(
config.web.timeouts.carrier_probe_coalesce_ms,
config.web.debug.bridge_diagnostics_enabled(),
config.web.carrier_method,
&config.web.retry_jitter,
&generation.rng,
);
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
+4
View File
@@ -409,6 +409,10 @@ async fn diagnostic_body_pressure_does_not_claim_the_event() {
response_header(split_response(&saturated).0, "retry-after"),
"1"
);
assert_eq!(
response_header(split_response(&saturated).0, "x-telemt-retry-after-ms"),
"250"
);
drop(held);
let accepted = request(&listener, &runtime, retry).await;
+5
View File
@@ -46,6 +46,11 @@ pub(super) fn service_unavailable() -> HttpResponse {
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
// Keep whole-second HTTP guidance for clients without the Telemt extension.
response.headers_mut().insert(
HeaderName::from_static("x-telemt-retry-after-ms"),
HeaderValue::from_static("250"),
);
response
}
+3
View File
@@ -133,6 +133,7 @@ pub(super) struct DetachedCarrierEvidence {
/// Result of one generation-fenced policy reconciliation.
pub(super) struct CarrierLearningPolicyOutcome {
/// Current epoch after the reconciliation.
#[cfg(test)]
pub(super) epoch: Option<u64>,
/// Whether this generation was current enough to apply.
pub(super) applied: bool,
@@ -215,6 +216,7 @@ impl CarrierLearning {
.is_some_and(|applied| generation < applied)
{
return CarrierLearningPolicyOutcome {
#[cfg(test)]
epoch: self.epoch,
applied: false,
detached: None,
@@ -239,6 +241,7 @@ impl CarrierLearning {
self.policy_started_at = now;
}
CarrierLearningPolicyOutcome {
#[cfg(test)]
epoch: self.epoch,
applied: true,
detached,
+2
View File
@@ -160,6 +160,7 @@ impl WebProcessRuntime {
}
/// Stops issuance and drains all WEB work until one absolute deadline.
#[cfg(test)]
pub(crate) async fn shutdown_until(
self: &std::sync::Arc<Self>,
deadline: TokioInstant,
@@ -168,6 +169,7 @@ impl WebProcessRuntime {
}
/// Stops issuance and drains WEB work under the currently configured budget.
#[cfg(test)]
pub(crate) async fn shutdown(self: &std::sync::Arc<Self>) -> WebShutdownOutcome {
let timeout_secs = self
.active_runtime
+1
View File
@@ -216,6 +216,7 @@ fn header_value_allowed(name: &hyper::header::HeaderName) -> bool {
| "x-carrier-failure"
| "x-carrier-mode"
| "retry-after"
| "x-telemt-retry-after-ms"
| "cache-control"
| "etag"
| "if-none-match"