Merge pull request #899 from telemt/flow-web-handshake

Flow web handshake
This commit is contained in:
Alexey
2026-08-23 11:01:29 +03:00
committed by GitHub
38 changed files with 451 additions and 412 deletions
+2 -2
View File
@@ -2542,7 +2542,7 @@ Hinweis: Dieser Abschnitt akzeptiert auch den Legacy-Alias `[server.admin_api]`
## web_client_ip_source (server.listeners)
- **Einschränkungen / Validierung**: Die erste WEB-Implementierung unterstützt ausschließlich `"x_forwarded_for"`.
- **Beschreibung**: Wählt die L7-Quelle der ursprünglichen Client-IP. Telemt akzeptiert genau eine kanonische `X-Forwarded-For`-Adresse und nur dann, wenn der direkte TCP-Peer zu `web_trusted_proxy_cidrs` gehört.
- **Beschreibung**: Wählt die L7-Quelle der ursprünglichen Client-IP. Von einem direkten TCP-Peer in `web_trusted_proxy_cidrs` akzeptiert Telemt genau eine syntaktisch gültige `X-Forwarded-For`-Adresse. Fehlt der Header bei einem vertrauenswürdigen Peer, verwendet Telemt dessen Adresse; konfigurieren Sie den TLS-Terminator so, dass der Header gesetzt wird, damit clientbezogene Limits und Quellrichtlinien die echte Client-Adresse verwenden.
## web_trusted_proxy_cidrs (server.listeners)
- **Einschränkungen / Validierung**: Nicht leeres CIDR-Array nur für WEB; ein `/0`-Netz wird abgelehnt. Für einen MTProxy-Listener ist das Feld ungültig.
@@ -2630,7 +2630,7 @@ Alle Timeouts werden in Sekunden angegeben und müssen im Bereich `1..=3600` lie
| `decoy` | Tabelle | ja | `` | Gewöhnlicher Site-Fallback für nicht authentifizierten oder ungültigen Datenverkehr. |
| `profiles` | Tabellen-Array | bei aktiviertem WEB | `` | Explizite Benutzer und Client-Secret-Modi für diesen Hostnamen. |
Die weitergeleitete Client-Adresse und `public_addr` müssen dieselbe IP-Familie verwenden. Der Hostname wird bei der Validierung normalisiert und muss von Telegram Desktop akzeptiert werden.
Der Hostname wird bei der Validierung normalisiert und muss von Telegram Desktop akzeptiert werden. Ein Bootstrap ist ein Bearer-Token: Client-Adresse und IP-Familie dürfen sich vor der Sitzungserstellung ändern. Ein ungenutzter Bootstrap bleibt über einen Konfigurations-Reload hinweg nur gültig, solange dieselbe Profilidentität aktiv bleibt.
# [web.vhosts.decoy]
+2 -2
View File
@@ -2542,7 +2542,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
## web_client_ip_source (server.listeners)
- **Constraints / validation**: Only `"x_forwarded_for"` is supported by the initial WEB implementation.
- **Description**: Chooses the L7 source of the original client IP. Telemt accepts exactly one canonical `X-Forwarded-For` address and only when the direct TCP peer belongs to `web_trusted_proxy_cidrs`.
- **Description**: Chooses the L7 source of the original client IP. From a direct TCP peer in `web_trusted_proxy_cidrs`, Telemt accepts one parseable `X-Forwarded-For` address. If the trusted peer omits the header, Telemt uses that peer's address; configure the terminator to set the header so per-client limits and source policy use the real client address.
## web_trusted_proxy_cidrs (server.listeners)
- **Constraints / validation**: WEB-only non-empty CIDR array. A `/0` network is rejected. It is invalid on an MTProxy listener.
@@ -2630,7 +2630,7 @@ Every timeout is measured in seconds and must be within `1..=3600`. The longest
| `decoy` | table | yes | `` | Ordinary-site fallback for unauthenticated or invalid traffic. |
| `profiles` | array of tables | when enabled | `` | Explicit users and client secret modes exposed by this hostname. |
The forwarded client address and `public_addr` must use the same IP family. The hostname must be accepted by Telegram Desktop and is normalized during validation.
The hostname must be accepted by Telegram Desktop and is normalized during validation. A bootstrap is a bearer credential: its client address and address family may change before session creation. An unused bootstrap remains valid across a configuration reload only while the same profile identity is still active.
# [web.vhosts.decoy]
+2 -2
View File
@@ -2468,7 +2468,7 @@
## web_client_ip_source (server.listeners)
- **Ограничения / валидация**: Первая реализация WEB поддерживает только `"x_forwarded_for"`.
- **Описание**: Выбирает L7-источник исходного IP клиента. Telemt принимает ровно один канонический адрес `X-Forwarded-For`, только если прямой TCP peer входит в `web_trusted_proxy_cidrs`.
- **Описание**: Выбирает L7-источник исходного IP клиента. От прямого TCP peer из `web_trusted_proxy_cidrs` Telemt принимает один корректно разбираемый адрес `X-Forwarded-For`. Если доверенный peer не передал header, Telemt использует его адрес; настройте TLS-терминатор на передачу header, чтобы per-client limits и source policy применялись к реальному адресу клиента.
## web_trusted_proxy_cidrs (server.listeners)
- **Ограничения / валидация**: Непустой массив CIDR только для WEB. Сеть `/0` запрещена. Параметр недопустим для MTProxy-listener’а.
@@ -2556,7 +2556,7 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут
| `decoy` | таблица | да | `✔` | Обычный сайт для неаутентифицированного или некорректного трафика. |
| `profiles` | массив таблиц | при включённом WEB | `✔` | Явные пользователи и client secret modes для этого hostname. |
Forwarded client address и `public_addr` должны относиться к одному семейству IP. Hostname нормализуется при валидации и должен приниматься Telegram Desktop.
Hostname нормализуется при валидации и должен приниматься Telegram Desktop. Bootstrap является bearer credential: адрес клиента и его IP-семейство могут измениться до создания session. Неиспользованный bootstrap остаётся действительным после reload конфигурации, только пока активен профиль с той же identity.
# [web.vhosts.decoy]
+7 -5
View File
@@ -14,7 +14,7 @@ Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS-C
Telegram Desktop
| HTTPS :443
v
NGINX oder HAProxy (TLS-Terminierung, kanonische Werte für Host und X-Forwarded-For)
NGINX oder HAProxy (TLS-Terminierung, kanonischer Host und eine X-Forwarded-For-Adresse)
| unverschlüsseltes HTTP/1.1 in einem privaten Netz
v
Telemt-WEB-Listener
@@ -30,6 +30,7 @@ Leiten Sie den vollständigen öffentlichen vhost an Telemt weiter. Wenn der TLS
- 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 = "https"` wählt serialisierte HTTPS-Uplinks und Long Polling. `web.carrier = "https-lanes"` wählt unabhängige HTTPS-Sequenzen und Polls pro logischem Stream. WebSocket-Carrier werden nicht angeboten.
- Capability-, Bootstrap- und Session-Zugangsdaten sind getrennte Werte mit begrenzter Lebensdauer. Carrier-Zugangsdaten sind geheim und dürfen nicht in Access-Logs erscheinen.
- Ein Bootstrap ist ein Bearer-Token und nicht an eine Quelladresse gebunden. Client-Adresse und IP-Familie dürfen sich zwischen dem Laden der Bridge und der Sitzungserstellung ändern. Die Ausstellungsadresse bleibt dem Limit ungenutzter Bootstraps zugeordnet; die Adresse des ersten gültigen Erstellungs-Requests wird der Sitzung zugeordnet.
- Die innere MTProxy-Authentifizierung ist auf den Benutzer und Secret-Modus des vhost-Profils beschränkt. Ein ungültiger innerer Handshake schließt nur seinen logischen Stream und gelangt niemals in den TCP-Masking-Pfad.
Telegram-Desktop-WEB-Links enthalten keinen Port, da der Client Port 443 voraussetzt:
@@ -49,7 +50,7 @@ Telemt gibt Links für die durch `[general.links].show` ausgewählten WEB-Profil
- Eine gewöhnliche Decoy-Site als privater HTTP-Origin oder unveränderlicher Snapshot eines lokalen Verzeichnisses.
- Ein kompatibler Telegram-Desktop-Build mit dem Proxy-Typ `WEB`.
Wenn ein Hostname sowohl über IPv4 als auch IPv6 bedient wird, verwenden Sie in dieser ersten Implementierung getrennte Hostnamen oder Telemt-Instanzen. Die weitergeleitete Client-Adresse und `public_addr` müssen dieselbe IP-Familie verwenden.
Die weitergeleitete Client-Adresse darf eine andere IP-Familie als `public_addr` verwenden und sich während der Bootstrap-Lebensdauer ändern. `public_addr` muss weiterhin den exakten öffentlichen Endpoint der inneren MTProxy-Route bezeichnen.
## Minimale Telemt-Konfiguration
@@ -151,7 +152,7 @@ server {
}
```
`client_max_body_size` muss mindestens `web.limits.max_body_bytes` entsprechen. `proxy_read_timeout` und `proxy_send_timeout` müssen größer als `web.timeouts.long_poll_secs` sein, dessen Default 25 Sekunden beträgt. Überschreiben Sie `X-Forwarded-For`, statt einen Wert anzuhängen. Aktivieren Sie keine Upstream-Wiederholungen: Der Bridge-Transport führt byte-identische Wiederholungen über sein eigenes Sequenzprotokoll aus.
`client_max_body_size` muss mindestens `web.limits.max_body_bytes` entsprechen. `proxy_read_timeout` und `proxy_send_timeout` müssen größer als `web.timeouts.long_poll_secs` sein, dessen Default 25 Sekunden beträgt. Überschreiben Sie `X-Forwarded-For`, statt einen Wert anzuhängen. Telemt akzeptiert eine syntaktisch gültige IP-Adresse; fehlt der Header bei einem vertrauenswürdigen TLS-Terminator, verwendet Telemt die Adresse des direkten Peers, doch clientbezogene Limits und Quellrichtlinien sehen dann den Terminator statt des echten Clients. Aktivieren Sie keine Upstream-Wiederholungen: Der Bridge-Transport führt byte-identische Wiederholungen über sein eigenes Sequenzprotokoll aus.
Öffentliches HTTP/2 ist für `https-lanes` obligatorisch; verwenden Sie die entsprechende HTTP/2-Direktive der installierten NGINX-Version. Der private Hop von NGINX zu Telemt bleibt absichtlich HTTP/1.1. Die Upstream-Verbindungskapazität muss die erwarteten gleichzeitigen Lane-Polls tragen; `keepalive` steuert den Idle-Pool und ist keine Nebenläufigkeitsgrenze.
@@ -253,13 +254,14 @@ Der vollständige Vertrag für Requests, Revisionen, Fehler und alle Benutzer-En
- Deaktivieren Sie am TLS-Terminator die Protokollierung von Request-Target und Authorization oder verwenden Sie ein geprüftes, redigiertes Format. Raw Queries enthalten Bridge-Capabilities und `Authorization` enthält Bootstrap- oder Session-Bearer-Zugangsdaten.
- Verwenden Sie pro vhost eine stabile öffentliche Adresse. Wenn DNS mehrere Ingress-Adressen liefert, muss jede Bereitstellung die Adresse ihres externen Pfads verwenden.
- Bootstrap- und Session-Register sind prozesslokal. Ein Multi-Prozess- oder Multi-Host-Upstream-Pool benötigt Affinität für den vollständigen vhost: Bridge-GET, Sitzungserstellung, Uplink, Downlink und DELETE. Ein einzelner Telemt-Prozess benötigt keine zusätzliche Affinität.
- Ein ungenutzter Bootstrap übersteht einen Konfigurations-Reload nur, wenn die exakte Profilidentität aktiv bleibt: Host, `public_addr`, Benutzer, Secret-Modus, Carrier und Capability. Bereits erstellte Sitzungen behalten ihren unveränderlichen Carrier und ihre Profilidentität und bleiben lifecycle-bounded.
- Der Decoy gehört zum Anti-Probing-Vertrag. Prüfen Sie sein gewöhnliches 404-Verhalten und die Antwortzeiten über den öffentlichen TLS-Endpunkt, bevor Sie Links verteilen.
## Erstprüfung
1. Starten Sie das neu erstellte Telemt-Binary mit der WEB-Konfiguration und prüfen Sie, dass der private Listener gebunden ist.
2. Prüfen Sie über den öffentlichen TLS-Endpunkt, dass `GET /`, ein unbekannter Pfad und eine ungültige `bridge`-Query die konfigurierte Decoy-Site zurückgeben.
3. Prüfen Sie, dass Telemt genau eine kanonische `X-Forwarded-For`-Adresse und `Host: proxy.example.com` oder `Host: proxy.example.com:443` erhält.
3. Prüfen Sie, dass Telemt genau eine syntaktisch gültige `X-Forwarded-For`-Adresse und `Host: proxy.example.com` oder `Host: proxy.example.com:443` erhält.
4. Importieren Sie den ausgegebenen `tg://webproxy`-Link in den vorgesehenen Telegram-Desktop-Build und stellen Sie eine Proxy-Verbindung her.
5. Bestätigen Sie für `https-lanes`, dass die öffentliche Verbindung HTTP/2 ausgehandelt hat, und testen Sie mindestens zwei gleichzeitige logische Streams; der private Hop zu Telemt bleibt HTTP/1.1.
6. Testen Sie einen Reconnect und mindestens einen Long Poll über 25 Sekunden, um sicherzustellen, dass Frontend-Timeouts den Carrier nicht abbrechen.
@@ -270,7 +272,7 @@ Der vollständige Vertrag für Requests, Revisionen, Fehler und alle Benutzer-En
| Symptom | Prüfung |
| --- | --- |
| WEB-Konfiguration ist auf dem Datenträger gültig, aber das Listener-Verhalten hat sich nicht geändert | Prüfen Sie `deferred_process_fields`; Listener- und `[web.limits]`-Änderungen erfordern einen Neustart. |
| Carrier-Requests erreichen den Decoy | Prüfen Sie den exakten vhost, den Secret-Modus des Links, das CIDR des direkten Proxys und genau einen kanonischen `X-Forwarded-For`-Wert. |
| Carrier-Requests erreichen den Decoy | Prüfen Sie den exakten vhost, den Secret-Modus des Links, das CIDR des direkten Proxys und genau einen syntaktisch gültigen `X-Forwarded-For`-Wert. |
| Long Polls werden nach einem festen Intervall getrennt | Setzen Sie Client-, Server-, Sende- und Lese-Timeouts von NGINX/HAProxy über `web.timeouts.long_poll_secs`. |
| `https-lanes` funktioniert, Streams blockieren sich aber weiterhin | Prüfen Sie die öffentliche HTTP/2-Aushandlung, die unveränderte Weitergabe von `X-Lane-ID` und genügend TLS-Terminator-Upstream-Verbindungen für parallele private HTTP/1.1-Polls. |
| Telegram Desktop lehnt den Link ab | Lassen Sie den Port weg und verwenden Sie einen gültigen FQDN, extern Port 443 sowie ausschließlich `plain` oder `dd`. |
+7 -5
View File
@@ -14,7 +14,7 @@ WEB mode carries ordinary MTProxy streams through bounded HTTPS carriers compati
Telegram Desktop
| HTTPS :443
v
NGINX or HAProxy (TLS termination, canonical Host and X-Forwarded-For)
NGINX or HAProxy (TLS termination, canonical Host and one X-Forwarded-For address)
| plain HTTP/1.1 on a private network
v
Telemt WEB listener
@@ -30,6 +30,7 @@ Route the complete public vhost to Telemt. Splitting only recognized carrier pat
- `plain` and `dd` 16-byte MTProxy secrets are supported. `ee` FakeTLS secrets are not supported by WEB mode.
- `web.carrier = "https"` selects serialized HTTPS uplink and long polling. `web.carrier = "https-lanes"` selects independent HTTPS sequencing and polling per logical stream. WebSocket carriers are not advertised.
- Capability, bootstrap, and session credentials are separate bounded-lifetime values. Carrier credentials must be treated as secrets and must not appear in access logs.
- A bootstrap is a bearer credential, not a source-address-bound token. The client address and IP family may change between bridge loading and session creation. The issuing address retains unused-bootstrap accounting, while the address on the first valid creation request owns the session.
- Inner MTProxy authentication is restricted to the user and secret mode selected by the vhost profile. Invalid inner handshakes close only their logical stream and never enter the TCP masking path.
Telegram Desktop WEB links omit a port because the client requires port 443:
@@ -49,7 +50,7 @@ Telemt prints links for WEB profiles selected by `[general.links].show` through
- A normal decoy site, either a private HTTP origin or an immutable local directory snapshot.
- A compatible Telegram Desktop build with the `WEB` proxy type.
If one hostname is served through both IPv4 and IPv6, use separate Telemt deployments or separate hostnames in this first implementation. The forwarded client address and `public_addr` must use the same IP family.
The forwarded client address may differ in family from `public_addr` and may change while a bootstrap is live. `public_addr` must still identify the exact public endpoint used by the inner MTProxy route.
## Minimal Telemt configuration
@@ -151,7 +152,7 @@ server {
}
```
`client_max_body_size` must be at least `web.limits.max_body_bytes`. `proxy_read_timeout` and `proxy_send_timeout` must exceed `web.timeouts.long_poll_secs`, which defaults to 25 seconds. Overwrite, rather than append to, `X-Forwarded-For`. Do not enable upstream retries: the bridge performs byte-identical retries through its own sequence protocol.
`client_max_body_size` must be at least `web.limits.max_body_bytes`. `proxy_read_timeout` and `proxy_send_timeout` must exceed `web.timeouts.long_poll_secs`, which defaults to 25 seconds. Overwrite, rather than append to, `X-Forwarded-For`. Telemt accepts one parseable IP address; if a trusted terminator omits the header, Telemt falls back to the direct peer address, but per-client limits and source policy then see the terminator rather than the real client. Do not enable upstream retries: the bridge performs byte-identical retries through its own sequence protocol.
Public HTTP/2 is mandatory for `https-lanes`; use the equivalent HTTP/2 directive supported by the installed NGINX release. The private NGINX-to-Telemt hop intentionally remains HTTP/1.1. Ensure the upstream connection capacity can sustain the expected simultaneous lane polls; `keepalive` controls the idle pool and is not a concurrency limit.
@@ -253,13 +254,14 @@ See the complete [Control API contract](../Architecture/API/API.md) for request
- Disable request-target and authorization logging at the TLS terminator, or use a verified redacted format. Raw queries contain bridge capabilities and `Authorization` contains bootstrap or session bearer credentials.
- Keep one stable public address per vhost. If DNS returns several ingress addresses, each deployment must use the address matching its external path.
- Bootstrap and session registries are process-local. A multi-process or multi-host upstream pool requires affinity for the complete vhost: bridge GET, session creation, uplink, downlink, and DELETE. A single Telemt process needs no extra affinity.
- An unused bootstrap survives a configuration reload only when the exact profile identity remains active: host, `public_addr`, user, secret mode, carrier, and capability. Existing created sessions retain their immutable carrier and profile identity and remain lifecycle-bounded.
- The decoy is part of the anti-probing contract. Verify its ordinary 404 behavior and response timing through the public TLS endpoint before distributing links.
## Initial verification
1. Start the rebuilt Telemt binary with the WEB configuration and confirm that the private listener is bound.
2. Confirm through the public TLS endpoint that `GET /`, an unknown path, and an invalid `bridge` query return the configured decoy site.
3. Confirm that Telemt receives one canonical `X-Forwarded-For` address and `Host: proxy.example.com` or `Host: proxy.example.com:443`.
3. Confirm that Telemt receives one parseable `X-Forwarded-For` address and `Host: proxy.example.com` or `Host: proxy.example.com:443`.
4. Import the printed `tg://webproxy` link in the intended Telegram Desktop build and establish a proxy connection.
5. For `https-lanes`, confirm that the public connection negotiated HTTP/2 and exercise at least two simultaneous logical streams; the private Telemt hop remains HTTP/1.1.
6. Exercise reconnect and at least one long poll beyond 25 seconds to prove the frontend timeouts do not truncate the carrier.
@@ -270,7 +272,7 @@ See the complete [Control API contract](../Architecture/API/API.md) for request
| Symptom | Check |
| --- | --- |
| WEB configuration is valid on disk but listener behavior did not change | Inspect reload `deferred_process_fields`; listener and `[web.limits]` changes require restart. |
| Carrier requests reach the decoy | Verify exact vhost, link secret mode, direct proxy CIDR, and one canonical `X-Forwarded-For` value. |
| Carrier requests reach the decoy | Verify exact vhost, link secret mode, direct proxy CIDR, and one parseable `X-Forwarded-For` value. |
| Long polls disconnect near a fixed interval | Raise NGINX/HAProxy client, server, send, and read timeouts above `web.timeouts.long_poll_secs`. |
| `https-lanes` works but streams still block each other | Confirm public HTTP/2 negotiation, preserve `X-Lane-ID`, and provide enough TLS-terminator upstream connections for concurrent private HTTP/1.1 polls. |
| Telegram Desktop rejects the link | Omit the port, use a valid FQDN, port 443 externally, and only `plain` or `dd` secret mode. |
+7 -5
View File
@@ -14,7 +14,7 @@ WEB-режим переносит обычные MTProxy-потоки через
Telegram Desktop
| HTTPS :443
v
NGINX или HAProxy (TLS termination, канонические Host и X-Forwarded-For)
NGINX или HAProxy (TLS termination, канонический Host и один адрес X-Forwarded-For)
| обычный HTTP/1.1 в приватной сети
v
WEB-listener Telemt
@@ -30,6 +30,7 @@ WEB-listener Telemt
- Поддерживаются 16-байтовые MTProxy-секреты `plain` и `dd`. FakeTLS-секреты `ee` в WEB-режиме не поддерживаются.
- `web.carrier = "https"` выбирает сериализованные HTTPS uplink и long polling. `web.carrier = "https-lanes"` выбирает независимые HTTPS sequencing и polling для каждого logical stream. WebSocket carriers не анонсируются.
- Capability, bootstrap и session credentials — отдельные значения с ограниченным сроком жизни. Carrier credentials считаются секретами и не должны попадать в access logs.
- Bootstrap является bearer credential, а не token с привязкой к source address. Адрес клиента и его IP-семейство могут измениться между загрузкой bridge и созданием session. Адрес выдачи продолжает учитываться в лимите неиспользованных bootstrap, а владельцем session становится адрес первого корректного запроса создания.
- Внутренняя MTProxy-аутентификация ограничена пользователем и режимом секрета, выбранными профилем vhost. Некорректный внутренний handshake закрывает только свой logical stream и никогда не попадает в TCP masking path.
В WEB-ссылках Telegram Desktop нет порта, потому что клиент требует порт 443:
@@ -49,7 +50,7 @@ Telemt печатает ссылки для WEB-профилей, выбранн
- Обычный decoy site: приватный HTTP origin либо immutable snapshot локального каталога.
- Совместимая сборка Telegram Desktop с типом прокси `WEB`.
Если один hostname обслуживается одновременно по IPv4 и IPv6, в первой реализации используйте отдельные hostname или отдельные экземпляры Telemt. Forwarded client address и `public_addr` должны принадлежать одному семейству IP.
Forwarded client address может принадлежать другому IP-семейству, чем `public_addr`, и изменяться в течение срока жизни bootstrap. При этом `public_addr` должен по-прежнему указывать точный публичный endpoint внутреннего MTProxy route.
## Минимальная конфигурация Telemt
@@ -151,7 +152,7 @@ server {
}
```
`client_max_body_size` должен быть не меньше `web.limits.max_body_bytes`. Значения `proxy_read_timeout` и `proxy_send_timeout` должны превышать `web.timeouts.long_poll_secs`, по умолчанию равный 25 секундам. Перезаписывайте `X-Forwarded-For`, а не дополняйте его. Не включайте upstream retries: byte-identical retry выполняет сам bridge по своему sequence protocol.
`client_max_body_size` должен быть не меньше `web.limits.max_body_bytes`. Значения `proxy_read_timeout` и `proxy_send_timeout` должны превышать `web.timeouts.long_poll_secs`, по умолчанию равный 25 секундам. Перезаписывайте `X-Forwarded-For`, а не дополняйте его. Telemt принимает один корректно разбираемый IP-адрес; если доверенный TLS-терминатор не передал header, Telemt использует адрес непосредственного peer, но per-client limits и source policy тогда видят терминатор вместо реального клиента. Не включайте upstream retries: byte-identical retry выполняет сам bridge по своему sequence protocol.
Для `https-lanes` обязателен публичный HTTP/2; используйте эквивалентную HTTP/2-директиву, поддерживаемую установленной версией NGINX. Приватный hop NGINX-to-Telemt намеренно остаётся HTTP/1.1. Upstream connection capacity должна выдерживать ожидаемое число одновременных lane polls; `keepalive` управляет idle pool и не является лимитом concurrency.
@@ -253,13 +254,14 @@ curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \
- Отключите логирование request target и authorization на TLS-терминаторе либо используйте проверенный формат с редактированием. Raw queries содержат bridge capabilities, а `Authorization` — bootstrap или session bearer credentials.
- Сохраняйте один стабильный публичный адрес на vhost. Если DNS возвращает несколько ingress addresses, каждый deployment должен использовать адрес своего внешнего пути.
- Bootstrap- и session-registries локальны для процесса. Для multi-process или multi-host upstream pool нужна affinity всего vhost: bridge GET, создание сессии, uplink, downlink и DELETE. Одному процессу Telemt дополнительная affinity не нужна.
- Неиспользованный bootstrap переживает reload конфигурации, только если остаётся активной точная identity профиля: host, `public_addr`, user, secret mode, carrier и capability. Уже созданные sessions сохраняют неизменные carrier и identity профиля и остаются lifecycle-bounded.
- Decoy входит в anti-probing contract. До распространения ссылок проверьте через публичный TLS endpoint его обычный ответ 404 и response timing.
## Первичная проверка
1. Запустите пересобранный Telemt с WEB-конфигурацией и убедитесь, что приватный listener привязан.
2. Через публичный TLS endpoint проверьте, что `GET /`, неизвестный path и некорректный query `bridge` возвращают настроенный decoy site.
3. Убедитесь, что Telemt получает один канонический адрес `X-Forwarded-For` и `Host: proxy.example.com` либо `Host: proxy.example.com:443`.
3. Убедитесь, что Telemt получает один корректно разбираемый адрес `X-Forwarded-For` и `Host: proxy.example.com` либо `Host: proxy.example.com:443`.
4. Импортируйте напечатанную ссылку `tg://webproxy` в целевую сборку Telegram Desktop и установите соединение через прокси.
5. Для `https-lanes` подтвердите согласование HTTP/2 на публичном connection и проверьте как минимум два одновременных logical streams; приватный hop к Telemt остаётся HTTP/1.1.
6. Проверьте reconnect и как минимум один long poll длительнее 25 секунд, чтобы frontend timeouts не обрывали carrier.
@@ -270,7 +272,7 @@ curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \
| Симптом | Что проверить |
| --- | --- |
| WEB-конфигурация валидна на диске, но поведение listener’а не изменилось | Проверьте `deferred_process_fields`; listener и `[web.limits]` требуют перезапуска. |
| Carrier-запросы попадают в decoy | Проверьте точный vhost, secret mode ссылки, CIDR непосредственного proxy и единственное каноническое значение `X-Forwarded-For`. |
| Carrier-запросы попадают в decoy | Проверьте точный vhost, secret mode ссылки, CIDR непосредственного proxy и единственное корректно разбираемое значение `X-Forwarded-For`. |
| Long polls разрываются через фиксированный интервал | Поднимите client, server, send и read timeouts NGINX/HAProxy выше `web.timeouts.long_poll_secs`. |
| `https-lanes` работает, но streams всё ещё блокируют друг друга | Проверьте согласование публичного HTTP/2, сохранение `X-Lane-ID` и достаточное число upstream connections TLS-терминатора для параллельных приватных HTTP/1.1 polls. |
| Telegram Desktop отклоняет ссылку | Не указывайте порт, используйте валидный FQDN, внешний порт 443 и только `plain` или `dd`. |
+7 -18
View File
@@ -47,10 +47,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let capability = derive_web_capability(
&client_secret[..client_secret_len],
vhost.host.as_bytes(),
)?;
let capability =
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{}` contains profiles with the same client capability",
@@ -97,9 +95,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).map_err(|_| {
ProxyError::Config("WEB capability secret must not be empty".to_string())
})?;
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
mac.update(WEB_CAPABILITY_CONTEXT);
mac.update(host);
Ok(mac.finalize().into_bytes().into())
@@ -159,13 +156,7 @@ fn build_decoy(
})
}
WebDecoyConfig::StaticDirectory { directory, index } => {
let site = load_static_site(
directory,
index,
limits,
static_files,
static_bytes,
)?;
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
}
}
@@ -239,8 +230,7 @@ fn load_static_directory(
})?;
if *total_files >= limits.max_static_files {
return Err(ProxyError::Config(
"WEB static entries exceed process-wide web.limits.max_static_files"
.to_string(),
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
));
}
*total_files += 1;
@@ -317,8 +307,7 @@ fn load_static_directory(
})?;
if *total_bytes > limits.max_static_bytes {
return Err(ProxyError::Config(
"WEB static snapshots exceed process-wide web.limits.max_static_bytes"
.to_string(),
"WEB static snapshots exceed process-wide web.limits.max_static_bytes".to_string(),
));
}
let relative = path.strip_prefix(root).map_err(|_| {
+27 -14
View File
@@ -59,9 +59,7 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
}
validate_limits(&config.web.limits)?;
if config.web.carrier == WebCarrier::HttpsLanes
&& config.web.limits.max_http_handlers < 2
{
if config.web.carrier == WebCarrier::HttpsLanes && config.web.limits.max_http_handlers < 2 {
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2");
}
validate_timeouts(&config.web.timeouts)?;
@@ -155,11 +153,20 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
("max_streams_per_session", limits.max_streams_per_session),
("max_streams_global", limits.max_streams_global),
("max_stream_handshakes", limits.max_stream_handshakes),
("pending_bytes_per_session", limits.pending_bytes_per_session),
(
"pending_bytes_per_session",
limits.pending_bytes_per_session,
),
("pending_bytes_global", limits.pending_bytes_global),
("pending_items_per_session", limits.pending_items_per_session),
(
"pending_items_per_session",
limits.pending_items_per_session,
),
("pending_items_global", limits.pending_items_global),
("control_bytes_per_session", limits.control_bytes_per_session),
(
"control_bytes_per_session",
limits.control_bytes_per_session,
),
("control_bytes_global", limits.control_bytes_global),
("max_bootstraps_global", limits.max_bootstraps_global),
("max_bootstraps_per_ip", limits.max_bootstraps_per_ip),
@@ -181,11 +188,16 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
("max_stream_handshakes", limits.max_stream_handshakes),
] {
if value > tokio::sync::Semaphore::MAX_PERMITS {
return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity"));
return config_error(&format!(
"web.limits.{field} exceeds Tokio semaphore capacity"
));
}
}
let rates = [
("new_bootstraps_per_minute", limits.new_bootstraps_per_minute),
(
"new_bootstraps_per_minute",
limits.new_bootstraps_per_minute,
),
("new_bootstraps_burst", limits.new_bootstraps_burst),
("new_sessions_per_minute", limits.new_sessions_per_minute),
("new_sessions_burst", limits.new_sessions_burst),
@@ -241,7 +253,9 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
let required_control_bytes_global = control_items_global
.checked_mul(control_frame_cost)
.ok_or_else(|| {
ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string())
ProxyError::Config(
"web.limits global control byte reservation overflowed usize".to_string(),
)
})?;
if control_items_per_session >= limits.pending_items_per_session
|| control_items_global >= limits.pending_items_global
@@ -367,10 +381,7 @@ fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let mut hosts = HashSet::with_capacity(config.web.vhosts.len());
let mut profile_count = 0usize;
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
vhost.host = normalize_web_host(
&vhost.host,
&format!("web.vhosts[{vhost_idx}].host"),
)?;
vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?;
if !hosts.insert(vhost.host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
}
@@ -404,7 +415,9 @@ fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
.max_streams_per_session
.unwrap_or(limits.max_streams_per_session);
if profile.max_sessions == Some(0)
|| profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global)
|| profile
.max_sessions
.is_some_and(|value| value > limits.max_sessions_global)
|| profile.max_streams == Some(0)
|| profile
.max_streams
@@ -94,10 +94,7 @@ fn web_semaphore_limits_are_rejected_before_runtime_construction() {
#[test]
fn web_ipv6_decoy_uses_a_valid_http_authority() {
let ipv6 = WEB_CONFIG.replace(
"http://127.0.0.1:18081",
"http://[::1]:18081",
);
let ipv6 = WEB_CONFIG.replace("http://127.0.0.1:18081", "http://[::1]:18081");
let config = load_config_from_temp_toml(&ipv6);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
+1 -1
View File
@@ -90,7 +90,7 @@ pub enum ListenerTransport {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WebClientIpSource {
/// Require exactly one canonical IP in `X-Forwarded-For`.
/// Use one parseable `X-Forwarded-For` address or the trusted direct peer.
#[default]
XForwardedFor,
}
+1 -4
View File
@@ -359,10 +359,7 @@ pub(crate) struct WebRuntimeProfile {
/// Runtime-ready ordinary-site fallback.
#[derive(Debug)]
pub(crate) enum WebRuntimeDecoy {
HttpUpstream {
addr: SocketAddr,
authority: String,
},
HttpUpstream { addr: SocketAddr, authority: String },
StaticDirectory(Arc<WebStaticSite>),
}
+1 -1
View File
@@ -10,11 +10,11 @@ use tokio_util::task::TaskTracker;
use crate::config::ProxyConfig;
use crate::crypto::SecureRandom;
use crate::ip_tracker::UserIpTracker;
use crate::proxy::authenticated::ClientRuntimeDeps;
#[cfg(test)]
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::proxy::authenticated::ClientRuntimeDeps;
use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool;
+1 -6
View File
@@ -301,12 +301,7 @@ impl ListenerSlot {
);
tokio::time::timeout(connection_stop_timeout, self.connections.wait())
.await
.map_err(|_| {
format!(
"listener {} connection shutdown timed out",
self.spec.addr
)
})?;
.map_err(|_| format!("listener {} connection shutdown timed out", self.spec.addr))?;
Ok(())
}
+6 -8
View File
@@ -4,16 +4,16 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::config::ProxyConfig;
use crate::config::ListenerTransport;
use crate::config::ProxyConfig;
use crate::maestro::generation::RuntimeGeneration;
use super::accept::ListenerSlot;
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
use super::plan::{ListenerBindSpec, listener_bind_plan};
use crate::web::manager::WebProcessRuntime;
#[cfg(unix)]
use super::unix::UnixAcceptHandle;
use crate::web::manager::WebProcessRuntime;
/// Process-owned listener inventory and accept-task lifecycle controller.
pub(crate) struct ListenerManager {
@@ -54,11 +54,7 @@ impl ListenerManager {
let addr = listener.spec.addr;
slots.insert(
addr,
ListenerSlot::start(
listener,
active_runtime.clone(),
web_runtime.clone(),
),
ListenerSlot::start(listener, active_runtime.clone(), web_runtime.clone()),
);
}
#[cfg(unix)]
@@ -103,7 +99,9 @@ impl ListenerManager {
.map(|(addr, spec)| (*addr, spec.clone()))
.collect::<BTreeMap<_, _>>();
if web_inventory_changed {
return Err("WEB listener inventory is process-owned; process restart required".to_string());
return Err(
"WEB listener inventory is process-owned; process restart required".to_string(),
);
}
let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
+1 -2
View File
@@ -406,8 +406,7 @@ pub(crate) fn resolve_reload_config(
fields.push("logging".to_string());
effective.logging = old.logging.clone();
}
if serde_json::to_value(&old.web.limits).ok()
!= serde_json::to_value(&desired.web.limits).ok()
if serde_json::to_value(&old.web.limits).ok() != serde_json::to_value(&desired.web.limits).ok()
{
fields.push("web.limits".to_string());
effective.web.limits = old.web.limits.clone();
+1 -3
View File
@@ -11,9 +11,7 @@ use crate::error::{ProxyError, Result};
use crate::ip_tracker::UserIpTracker;
use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack;
use crate::proxy::handshake::HandshakeSuccess;
use crate::proxy::middle_relay::{
handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack,
};
use crate::proxy::middle_relay::{handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack};
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
use crate::stats::Stats;
+2 -4
View File
@@ -43,9 +43,7 @@ use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_pro
use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated};
#[cfg(test)]
use crate::proxy::authenticated::{
UserConnectionReservation, acquire_user_connection_reservation,
};
use crate::proxy::authenticated::{UserConnectionReservation, acquire_user_connection_reservation};
use crate::proxy::handshake::{
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
@@ -53,9 +51,9 @@ use crate::proxy::handshake::{
#[cfg(test)]
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
use crate::proxy::masking::handle_bad_client_with_shared;
use crate::proxy::route_mode::RouteRuntimeController;
#[cfg(test)]
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
+1 -1
View File
@@ -57,8 +57,8 @@ use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate};
pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState};
#[cfg(test)]
pub use self::mtproto::handle_mtproto_handshake;
pub use self::mtproto::handle_mtproto_handshake_with_shared;
pub(crate) use self::mtproto::handle_mtproto_handshake_for_web_user;
pub use self::mtproto::handle_mtproto_handshake_with_shared;
#[allow(unused_imports)]
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
pub use self::session::HandshakeSuccess;
+1 -6
View File
@@ -274,12 +274,7 @@ pub(super) fn mode_enabled_for_proto(
proto_tag: ProtoTag,
is_tls: bool,
) -> bool {
mode_enabled_for_proto_with_policy(
config,
proto_tag,
is_tls,
MtprotoModePolicy::Configured,
)
mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, MtprotoModePolicy::Configured)
}
fn mode_enabled_for_proto_with_policy(
+11 -3
View File
@@ -203,15 +203,23 @@ where
}
let mut matched = exact_user_id.is_some_and(|user_id| try_user_id!(user_id));
if exact_user.is_none() && let Some(user_id) = sticky_ip_hint {
if exact_user.is_none()
&& let Some(user_id) = sticky_ip_hint
{
matched = try_user_id!(user_id);
}
if exact_user.is_none() && !matched && let Some(user_id) = preferred_user_id {
if exact_user.is_none()
&& !matched
&& let Some(user_id) = preferred_user_id
{
matched = try_user_id!(user_id);
}
if exact_user.is_none() && !matched && let Some(user_id) = sticky_prefix_hint {
if exact_user.is_none()
&& !matched
&& let Some(user_id) = sticky_prefix_hint
{
matched = try_user_id!(user_id);
}
+35 -12
View File
@@ -114,8 +114,13 @@ function joinPending(values,lane){
return {body:joined.buffer,total,count};
}
function retryAfterMs(response){
const value=Number(response.headers.get('Retry-After'));
return Number.isFinite(value)&&value>=0?Math.min(value*1000,30000):0;
const header=response.headers.get('Retry-After');
if(!header)return 0;
const seconds=Number(header);
if(Number.isFinite(seconds)&&seconds>=0)return 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}
return 0;
}
async function request(path,makeOptions){
let delay=250,attempt=0;const deadline=Date.now()+90000;
@@ -142,13 +147,11 @@ async function createSession(first){
try{
status('connecting');
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session rejected');
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session creation rejected');
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0';
if(!/^[A-Za-z0-9_-]{43}$/.test(sessionToken)||downCursor!=='0')throw new Error('invalid session metadata');
if(!sessionToken)throw new Error('missing session token');
if(closed){deleteSession();return}
const welcome=await response.arrayBuffer();
const welcomeBytes=new Uint8Array(welcome);
if(welcomeBytes.length!==8||welcomeBytes[0]!==17||welcomeBytes.slice(1).some(value=>value!==0))throw new Error('invalid welcome');
port.postMessage(welcome,[welcome]);status('connected');
if(carrier==='https-lanes')ensureLane(0);
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
@@ -234,7 +237,8 @@ async function pollLane(lane){
}
if(response.status!==200)throw new Error('lane downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
if(!next||!data.byteLength||splitFrames(data).some(value=>value.id!==lane.id))throw new Error('invalid lane downlink response');
if(!next||!data.byteLength)throw new Error('invalid lane downlink response');
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
}
@@ -268,11 +272,14 @@ addEventListener('message',event=>{
let source;try{source=new URL(event.origin)}catch(error){return}
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
activatePort(event.ports[0]);
});
},{once:false});
const androidBridge=globalThis.TelegramWebProxy;
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
if(value instanceof ArrayBuffer){for(const item of splitFrames(value))androidBridge.postMessage(item.data)}else androidBridge.postMessage(JSON.stringify(value));
if(value instanceof ArrayBuffer){
let frames;try{frames=splitFrames(value)}catch(error){fail();return}
for(const frame of frames)androidBridge.postMessage(frame.data);
}else androidBridge.postMessage(JSON.stringify(value));
}};
androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
@@ -304,8 +311,24 @@ mod tests {
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("carrier='https-lanes'"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page
.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*"));
assert!(page.body.contains("const when=Date.parse(header)"));
assert!(page.body.contains("},{once:false});"));
assert!(
page.body
.contains("if(!sessionToken)throw new Error('missing session token')")
);
assert!(!page.body.contains("welcomeBytes"));
assert!(
page.body
.contains("for(const value of splitFrames(data))if(value.id!==lane.id)")
);
assert!(
page.body
.contains("let frames;try{frames=splitFrames(value)}catch(error){fail();return}")
);
assert!(
page.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*")
);
}
}
+4 -9
View File
@@ -98,15 +98,10 @@ pub(crate) fn parse_all<'a>(
return Err(FrameError::Incomplete);
}
let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?;
let stream_id = u32::from(remaining[1]) << 16
| u32::from(remaining[2]) << 8
| u32::from(remaining[3]);
let payload_len = u32::from_be_bytes([
remaining[4],
remaining[5],
remaining[6],
remaining[7],
]) as usize;
let stream_id =
u32::from(remaining[1]) << 16 | u32::from(remaining[2]) << 8 | u32::from(remaining[3]);
let payload_len =
u32::from_be_bytes([remaining[4], remaining[5], remaining[6], remaining[7]]) as usize;
if payload_len > limits.max_frame_payload_bytes {
return Err(FrameError::PayloadLimit);
}
+13 -30
View File
@@ -34,9 +34,9 @@ mod request;
#[cfg(test)]
mod tests;
use decoy::serve_decoy;
use activity::{ActivityBody, RequestActivity};
use body::{CollectBodyError, CollectedBody, collect_body};
use decoy::serve_decoy;
use request::{
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
canonical_u64_header, client_ip, match_profile,
@@ -84,10 +84,7 @@ pub(crate) async fn serve_connection(
} else {
service_unavailable()
};
let response = response.map(|body| {
ActivityBody::new(body, activity)
.boxed_unsync()
});
let response = response.map(|body| ActivityBody::new(body, activity).boxed_unsync());
Ok::<_, Infallible>(response)
}
});
@@ -174,12 +171,7 @@ async fn handle_root(
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
return serve_decoy(request, vhost, false, &runtime).await;
};
let Some(client_ip) = client_ip(
&request,
peer,
client_ip_source,
trusted_proxy_cidrs,
) else {
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
@@ -242,21 +234,14 @@ async fn handle_api(
if request.uri().query().is_some() || request.headers().contains_key(header::COOKIE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some(client_ip) = client_ip(
&request,
peer,
client_ip_source,
trusted_proxy_cidrs,
) else {
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(token_hash) = bearer_token_hash(&request) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
match request.uri().path() {
"/api/v1/session" => {
handle_session(request, runtime, vhost, token_hash, client_ip).await
}
"/api/v1/session" => handle_session(request, runtime, vhost, token_hash, client_ip).await,
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
_ => serve_decoy(request, vhost, true, &runtime).await,
@@ -356,7 +341,12 @@ async fn handle_up(
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let limit = runtime.active_generation().config().web.limits.max_body_bytes;
let limit = runtime
.active_generation()
.config()
.web
.limits
.max_body_bytes;
let CollectedBody {
request,
body,
@@ -510,10 +500,7 @@ fn bad_gateway() -> HttpResponse {
}
fn generic_not_found() -> HttpResponse {
full_response(
StatusCode::NOT_FOUND,
Bytes::from_static(b"not found\n"),
)
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
}
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
@@ -523,11 +510,7 @@ fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
.boxed_unsync();
let mut response = Response::new(body);
*response.status_mut() = status;
insert_header(
&mut response,
header::CONTENT_LENGTH,
&length.to_string(),
);
insert_header(&mut response, header::CONTENT_LENGTH, &length.to_string());
response
}
+1 -4
View File
@@ -37,10 +37,7 @@ pub(super) struct ActivityBody {
impl ActivityBody {
/// Binds one response body to its request activity guard.
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
Self {
inner,
activity,
}
Self { inner, activity }
}
}
+3 -9
View File
@@ -2,8 +2,8 @@ use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Limited};
use hyper::body::{Body as _, Incoming};
use hyper::Request;
use hyper::body::{Body as _, Incoming};
use crate::web::manager::WebProcessRuntime;
@@ -50,14 +50,8 @@ pub(super) async fn collect_body(
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
return Err(CollectBodyError::Limit);
};
let body_timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.body_secs,
);
let body_timeout =
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs);
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
Ok(Ok(body)) => body.to_bytes(),
_ => {
+13 -16
View File
@@ -11,8 +11,7 @@ use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
use super::{
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
insert_header,
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found, insert_header,
};
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
use crate::web::manager::WebProcessRuntime;
@@ -128,14 +127,13 @@ fn static_entry<B>(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
response
.headers_mut()
.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
response
}
fn resolve_static_path<'a>(
path: &str,
site: &'a crate::config::WebStaticSite,
) -> Option<&'a str> {
fn resolve_static_path<'a>(path: &str, site: &'a crate::config::WebStaticSite) -> Option<&'a str> {
if !path.starts_with('/')
|| path.contains('\\')
|| path.contains("//")
@@ -151,7 +149,10 @@ fn resolve_static_path<'a>(
path
};
if site.assets.contains_key(route) {
return site.assets.get_key_value(route).map(|(key, _)| key.as_str());
return site
.assets
.get_key_value(route)
.map(|(key, _)| key.as_str());
}
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
return Some("/favicon.svg");
@@ -198,20 +199,16 @@ async fn proxy_to_upstream(
.max_header_bytes;
let mut builder = hyper::client::conn::http1::Builder::new();
builder.max_buf_size(max_header_bytes);
let (mut sender, connection) = match tokio::time::timeout(
header_timeout,
builder.handshake(TokioIo::new(stream)),
)
.await
{
let (mut sender, connection) =
match tokio::time::timeout(header_timeout, builder.handshake(TokioIo::new(stream))).await {
Ok(Ok(parts)) => parts,
_ => return bad_gateway(),
};
runtime.spawn_auxiliary(async move {
let _ = connection.await;
});
let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await
{
let mut response =
match tokio::time::timeout(header_timeout, sender.send_request(request)).await {
Ok(Ok(response)) => response,
_ => return bad_gateway(),
};
+60 -30
View File
@@ -2,15 +2,13 @@ use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use base64::Engine as _;
use hyper::header;
use hyper::Request;
use hyper::header;
use ipnetwork::IpNetwork;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::config::{
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
};
use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost};
use crate::web::manager::TokenHash;
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
@@ -26,14 +24,13 @@ pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
return None;
}
let host = value.strip_suffix(":443").unwrap_or(value);
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase())
{
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase()) {
return None;
}
Some(host)
}
/// Accepts one canonical forwarded client address from an explicitly trusted peer.
/// Accepts one forwarded client address or the direct address of a trusted peer.
pub(super) fn client_ip<B>(
request: &Request<B>,
peer: SocketAddr,
@@ -51,16 +48,17 @@ pub(super) fn client_ip<B>(
};
let values = request.headers().get_all(header_name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some()
|| value.is_empty()
|| value.trim() != value
|| value.contains(',')
{
let Some(value) = values.next() else {
return Some(peer.ip());
};
let value = value.to_str().ok()?;
if values.next().is_some() || value.trim() != value || value.contains(',') {
return None;
}
let ip = value.parse::<IpAddr>().ok()?;
(ip.to_string() == value).then_some(ip)
if value.is_empty() {
return Some(peer.ip());
}
value.parse::<IpAddr>().ok()
}
/// Decodes an exact canonical bridge query without allocating credential strings.
@@ -73,14 +71,14 @@ pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
return (candidate, false);
}
let mut decoded = [0u8; 32];
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode_slice(value, &mut decoded)
let Ok(decoded_len) =
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded)
else {
return (candidate, false);
};
let mut canonical = [0u8; 43];
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode_slice(decoded, &mut canonical)
let Ok(encoded_len) =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical)
else {
return (candidate, false);
};
@@ -113,8 +111,7 @@ pub(super) fn bearer_token_hash<B>(request: &Request<B>) -> Option<TokenHash> {
let values = request.headers().get_all(header::AUTHORIZATION);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1
{
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1 {
return None;
}
let token = value.strip_prefix("Bearer ")?;
@@ -145,10 +142,7 @@ pub(super) fn binary_content_type<B>(request: &Request<B>) -> bool {
}
/// Parses one canonical unsigned decimal carrier sequence header.
pub(super) fn canonical_u64_header<B>(
request: &Request<B>,
name: &'static str,
) -> Option<u64> {
pub(super) fn canonical_u64_header<B>(request: &Request<B>, name: &'static str) -> Option<u64> {
let values = request.headers().get_all(name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
@@ -176,16 +170,13 @@ mod tests {
}
#[test]
fn host_and_forwarded_identity_require_canonical_single_values() {
fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
let request = Request::builder()
.header(header::HOST, "proxy.example.com:443")
.header("x-forwarded-for", "192.0.2.10")
.body(())
.unwrap();
assert_eq!(
canonical_request_host(&request),
Some("proxy.example.com")
);
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
assert_eq!(
client_ip(
@@ -197,6 +188,45 @@ mod tests {
Some("192.0.2.10".parse().unwrap())
);
let expanded_ipv6 = Request::builder()
.header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
.body(())
.unwrap();
assert_eq!(
client_ip(
&expanded_ipv6,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
),
Some("2001:db8::10".parse().unwrap())
);
let without_forwarded_address = Request::builder().body(()).unwrap();
assert_eq!(
client_ip(
&without_forwarded_address,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
),
Some("127.0.0.1".parse().unwrap())
);
let empty_forwarded_address = Request::builder()
.header("x-forwarded-for", "")
.body(())
.unwrap();
assert_eq!(
client_ip(
&empty_forwarded_address,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
),
Some("127.0.0.1".parse().unwrap())
);
let uppercase = Request::builder()
.header(header::HOST, "Proxy.Example.com")
.body(())
+139 -21
View File
@@ -122,15 +122,6 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let wrong_family = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let wrong_family_response = request(&listener, &runtime, wrong_family).await;
let (_, wrong_family_body) = split_response(&wrong_family_response);
assert!(!wrong_family_body
.windows(11)
.any(|value| value == b"bootstrap='"));
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
@@ -173,10 +164,8 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
let session = response_header(create_headers, "x-session-token");
assert_eq!(session.len(), 43);
let replacement = test_runtime_generation(
2,
runtime_config(capability, WebCarrier::HttpsLanes),
);
let replacement =
test_runtime_generation(2, runtime_config(capability, WebCarrier::HttpsLanes));
active_runtime.store(Arc::clone(&replacement));
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let retry_response = request(&listener, &runtime, create_retry).await;
@@ -192,10 +181,16 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
.into_bytes();
let next_root_response = request(&listener, &runtime, next_root).await;
let (_, next_root_body) = split_response(&next_root_response);
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
assert!(next_root_body
assert!(
next_root_body
.windows(11)
.any(|value| value == b"bootstrap='")
);
assert!(
next_root_body
.windows(21)
.any(|value| value == b"carrier='https-lanes'"));
.any(|value| value == b"carrier='https-lanes'")
);
let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
@@ -214,13 +209,127 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
replacement.stop_background_tasks().await;
}
#[tokio::test]
async fn bootstrap_survives_client_address_family_change() {
let capability = [8u8; 32];
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (_, root_body) = split_response(&root_response);
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap='")
.and_then(|(_, suffix)| suffix.split_once('\''))
.map(|(token, _)| token)
.unwrap();
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut create = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
assert!(create_response.starts_with(b"HTTP/1.1 200"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn unused_bootstrap_survives_equivalent_runtime_generation_swap() {
let capability = [10u8; 32];
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (_, root_body) = split_response(&root_response);
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap='")
.and_then(|(_, suffix)| suffix.split_once('\''))
.map(|(token, _)| token)
.unwrap();
let replacement = test_runtime_generation(2, runtime_config(capability, WebCarrier::Https));
active_runtime.store(Arc::clone(&replacement));
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut create = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
assert!(create_response.starts_with(b"HTTP/1.1 200"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
replacement.stop_sessions().await;
replacement.stop_background_tasks().await;
}
#[tokio::test]
async fn unused_bootstrap_is_rejected_after_profile_identity_change() {
let capability = [11u8; 32];
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (_, root_body) = split_response(&root_response);
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap='")
.and_then(|(_, suffix)| suffix.split_once('\''))
.map(|(token, _)| token)
.unwrap();
let replacement =
test_runtime_generation(2, runtime_config(capability, WebCarrier::HttpsLanes));
active_runtime.store(Arc::clone(&replacement));
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut create = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
assert!(!create_response.starts_with(b"HTTP/1.1 200"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
replacement.stop_sessions().await;
replacement.stop_background_tasks().await;
}
#[tokio::test]
async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
let capability = [9u8; 32];
let generation = test_runtime_generation(
1,
runtime_config(capability, WebCarrier::HttpsLanes),
);
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::HttpsLanes));
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -248,7 +357,10 @@ async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
let (create_headers, _) = split_response(&create_response);
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https-lanes");
assert_eq!(
response_header(create_headers, "x-carrier-mode"),
"https-lanes"
);
let session = response_header(create_headers, "x-session-token").to_string();
let pong = frame::encode(FrameType::Pong, 0, &[]);
@@ -262,6 +374,12 @@ async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
let (uplink_headers, _) = split_response(&uplink_response);
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
assert!(
!std::str::from_utf8(uplink_headers)
.unwrap()
.lines()
.any(|line| line.to_ascii_lowercase().starts_with("content-length:"))
);
let mut missing_lane = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 2\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+19 -42
View File
@@ -89,9 +89,7 @@ pub(crate) struct WebProcessRuntime {
impl WebProcessRuntime {
/// Starts one process-scoped manager using immutable allocation ceilings.
pub(crate) fn start(
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
) -> Arc<Self> {
pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> {
let limits = active_runtime.load().config().web.limits.clone();
let runtime = Arc::new(Self {
active_runtime,
@@ -169,9 +167,7 @@ impl WebProcessRuntime {
/// Reserves one logical stream in the inner MTProxy handshake phase.
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.stream_handshakes)
.try_acquire_owned()
.ok();
let permit = Arc::clone(&self.stream_handshakes).try_acquire_owned().ok();
if permit.is_none() {
self.record_stream_rejected();
}
@@ -215,7 +211,7 @@ impl WebProcessRuntime {
Some((reader, body))
}
/// Issues a one-use bootstrap credential for the active generation.
/// Issues a one-use bootstrap credential for an active compatible profile.
pub(crate) fn issue_bootstrap(
&self,
profile: Arc<WebRuntimeProfile>,
@@ -229,17 +225,18 @@ impl WebProcessRuntime {
.as_ref()
.and_then(|runtime| matching_profile(runtime, &profile))
.ok_or(ManagerError::Authentication)?;
if !config.web.enabled
|| profile.public_addr.is_ipv4() != client_ip.is_ipv4()
|| !generation.proxy_shared.is_user_enabled(&profile.user)
{
if !config.web.enabled || !generation.proxy_shared.is_user_enabled(&profile.user) {
return Err(ManagerError::Closed);
}
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
if state.closed
|| state.bootstraps_per_ip.get(&client_ip).copied().unwrap_or(0)
|| state
.bootstraps_per_ip
.get(&client_ip)
.copied()
.unwrap_or(0)
>= self.limits.max_bootstraps_per_ip
|| !allow_rate(
&mut state.bootstrap_rate,
@@ -264,7 +261,6 @@ impl WebProcessRuntime {
state.bootstraps.insert(
hash,
Bootstrap {
generation_id: generation.id,
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
issued_at: now,
issuance_ip: client_ip,
@@ -281,15 +277,12 @@ impl WebProcessRuntime {
/// Checks whether a bootstrap token is live before reading a request body.
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
let generation_id = self.active_runtime.load().id;
let now = Instant::now();
let state = self.state.lock();
state.bootstraps.get(&hash).is_some_and(|entry| {
entry.profile.host == host
&& now <= entry.expires_at
&& (entry.generation_id == generation_id
|| entry.used && entry.session.is_some())
})
state
.bootstraps
.get(&hash)
.is_some_and(|entry| entry.profile.host == host && now <= entry.expires_at)
}
/// Creates a session exactly once or replays the original successful result.
@@ -320,18 +313,12 @@ impl WebProcessRuntime {
if !digest_matches {
return Err(ManagerError::Authentication);
}
let session = entry
.session
.as_ref()
.ok_or(ManagerError::Authentication)?;
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
return Ok(CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
});
}
if entry.generation_id != generation.id {
return Err(ManagerError::Authentication);
}
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
@@ -340,10 +327,7 @@ impl WebProcessRuntime {
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &entry.profile))
.filter(|profile| {
profile.public_addr.is_ipv4() == client_ip.is_ipv4()
&& generation.proxy_shared.is_user_enabled(&profile.user)
})
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if state.sessions.len() >= self.limits.max_sessions_global
@@ -461,14 +445,11 @@ impl WebProcessRuntime {
let fits = if control {
bytes <= self.limits.control_bytes_global
&& items <= control_item_reserve
&& state.pending_bytes
<= self.limits.pending_bytes_global.saturating_sub(bytes)
&& state.pending_items
<= self.limits.pending_items_global.saturating_sub(items)
&& state.pending_bytes <= self.limits.pending_bytes_global.saturating_sub(bytes)
&& state.pending_items <= self.limits.pending_items_global.saturating_sub(items)
&& state.pending_control_bytes
<= self.limits.control_bytes_global.saturating_sub(bytes)
&& state.pending_control_items
<= control_item_reserve.saturating_sub(items)
&& state.pending_control_items <= control_item_reserve.saturating_sub(items)
} else {
let data_bytes = state
.pending_bytes
@@ -477,10 +458,7 @@ impl WebProcessRuntime {
.pending_items
.saturating_sub(state.pending_control_items);
let (byte_limit, item_limit) = if downlink {
let uplink_bytes = self
.limits
.max_body_bytes
.saturating_add(
let uplink_bytes = self.limits.max_body_bytes.saturating_add(
self.limits
.max_frames_per_body
.saturating_mul(crate::web::session::QUEUE_ITEM_COST),
@@ -544,5 +522,4 @@ impl WebProcessRuntime {
fn record_limit_hit(&self) {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
}
}
+4 -18
View File
@@ -2,9 +2,7 @@ use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::Ordering;
use std::time::Instant;
use super::state::{
allocate_stream_port, allow_rate, decrement_map, release_stream_port,
};
use super::state::{allocate_stream_port, allow_rate, decrement_map, release_stream_port};
use super::{ProfileKey, WebProcessRuntime};
impl WebProcessRuntime {
@@ -43,10 +41,7 @@ impl WebProcessRuntime {
return None;
};
state.streams_live += 1;
*state
.streams_per_profile
.entry(profile_key)
.or_insert(0) += 1;
*state.streams_per_profile.entry(profile_key).or_insert(0) += 1;
self.streams_opened.fetch_add(1, Ordering::Relaxed);
Some(peer_port)
}
@@ -105,12 +100,7 @@ mod tests {
let downlink_bytes = data_bytes - uplink_bytes;
let downlink_items = data_items - runtime.limits.max_frames_per_body;
assert!(runtime.try_reserve_pending(
downlink_bytes,
downlink_items,
false,
true,
));
assert!(runtime.try_reserve_pending(downlink_bytes, downlink_items, false, true,));
assert!(runtime.try_reserve_pending(
uplink_bytes,
runtime.limits.max_frames_per_body,
@@ -120,11 +110,7 @@ mod tests {
assert!(!runtime.try_reserve_pending(1, 1, false, true));
runtime.release_pending(downlink_bytes, downlink_items, false);
runtime.release_pending(
uplink_bytes,
runtime.limits.max_frames_per_body,
false,
);
runtime.release_pending(uplink_bytes, runtime.limits.max_frames_per_body, false);
runtime.shutdown().await;
}
}
+1 -15
View File
@@ -4,10 +4,8 @@ use std::time::{Duration, Instant};
use tracing::info;
use super::state::{ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked};
use super::{ProfileKey, TokenHash, WebProcessRuntime};
use super::state::{
ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked,
};
impl WebProcessRuntime {
/// Removes one closed session and retains a bounded host-bound replay marker.
@@ -124,22 +122,10 @@ impl WebProcessRuntime {
/// Expires credentials and closes idle sessions without holding locks across callbacks.
pub(super) fn cleanup(&self) {
let generation_id = self.active_runtime.load().id;
let now = Instant::now();
let sessions = {
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let stale_bootstraps = state
.bootstraps
.iter()
.filter_map(|(hash, bootstrap)| {
(bootstrap.generation_id != generation_id && !bootstrap.used)
.then_some(*hash)
})
.collect::<Vec<_>>();
for hash in stale_bootstraps {
remove_bootstrap_locked(&mut state, hash);
}
state.sessions.values().cloned().collect::<Vec<_>>()
};
for session in sessions.into_iter().filter(|session| session.is_idle(now)) {
+6 -14
View File
@@ -14,13 +14,11 @@ use crate::web::session::WebSession;
/// One issued bootstrap and optional idempotent session-creation replay state.
pub(super) struct Bootstrap {
/// Generation that issued the bootstrap.
pub(super) generation_id: u64,
/// Credential and replay-state expiry deadline.
pub(super) expires_at: Instant,
/// Stable ordering point used for bounded eviction.
pub(super) issued_at: Instant,
/// Forwarded client address that owns this credential.
/// Issuing address charged for the unused-bootstrap quota.
pub(super) issuance_ip: IpAddr,
/// Immutable profile selected during capability validation.
pub(super) profile: Arc<WebRuntimeProfile>,
@@ -137,17 +135,11 @@ pub(super) fn matching_profile(
}
/// Applies one token-bucket admission decision at a caller-supplied monotonic time.
pub(super) fn allow_rate(
state: &mut RateState,
now: Instant,
per_minute: u32,
burst: u32,
) -> bool {
pub(super) fn allow_rate(state: &mut RateState, now: Instant, per_minute: u32, burst: u32) -> bool {
let burst = f64::from(burst);
if let Some(last) = state.last {
let elapsed = now.saturating_duration_since(last).as_secs_f64();
state.tokens =
(state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
state.tokens = (state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
} else {
state.tokens = burst;
}
@@ -218,9 +210,9 @@ where
/// Computes the process-wide item reserve required for session control progress.
pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize {
limits.max_sessions_global.saturating_mul(
16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)),
)
limits
.max_sessions_global
.saturating_mul(16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)))
}
/// Allocates a non-zero source port unique among live streams for one KDF route.
+7 -5
View File
@@ -314,7 +314,9 @@ impl WebSession {
if !self.queue_window_locked(&mut state, stream_id, count as u32) {
drop(state);
self.close();
return Poll::Ready(Err(io::Error::other("WEB session control budget exhausted")));
return Poll::Ready(Err(io::Error::other(
"WEB session control budget exhausted",
)));
}
Poll::Ready(Ok(()))
}
@@ -368,14 +370,14 @@ impl WebSession {
/// Returns the process queue-capacity notification source while the manager lives.
pub(super) fn budget_notify(&self) -> Option<Arc<Notify>> {
self.manager.upgrade().map(|manager| manager.budget_notify())
self.manager
.upgrade()
.map(|manager| manager.budget_notify())
}
fn release_stream_reservation(&self, peer_port: u16) {
let removed = self.state.lock().active_peer_ports.remove(&peer_port);
if removed
&& let Some(manager) = self.manager.upgrade()
{
if removed && let Some(manager) = self.manager.upgrade() {
manager.release_stream(
self.profile_key,
self.client_ip,
+2 -4
View File
@@ -3,9 +3,9 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::proxy::shared_state::ConntrackClosePolicy;
use crate::web::frame::FrameType;
use crate::web::stream::WebLogicalStream;
use crate::proxy::shared_state::ConntrackClosePolicy;
use super::{WebSession, inbound_queue_cost};
@@ -70,9 +70,7 @@ impl WebSession {
});
(queued, reserved)
};
if reserved
&& let Some(manager) = self.manager.upgrade()
{
if reserved && let Some(manager) = self.manager.upgrade() {
manager.release_stream(
self.profile_key,
self.client_ip,
+7 -25
View File
@@ -3,8 +3,7 @@ use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use super::{
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
WebSession,
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession,
};
use crate::config::WebCarrier;
use crate::web::frame::{self, FrameType};
@@ -117,9 +116,8 @@ impl WebSession {
.limits
.pending_bytes_per_session
.saturating_sub(self.limits.control_bytes_per_session);
let item_reserve = 16usize.saturating_add(
self.limits.max_streams_per_session.saturating_mul(3),
);
let item_reserve =
16usize.saturating_add(self.limits.max_streams_per_session.saturating_mul(3));
let data_item_limit = self
.limits
.pending_items_per_session
@@ -146,10 +144,7 @@ impl WebSession {
.pending_items
.saturating_sub(state.pending_control_items);
let (byte_limit, item_limit) = if class == PendingClass::Downlink {
let uplink_bytes = self
.limits
.max_body_bytes
.saturating_add(
let uplink_bytes = self.limits.max_body_bytes.saturating_add(
self.limits
.max_frames_per_body
.saturating_mul(QUEUE_ITEM_COST),
@@ -172,12 +167,7 @@ impl WebSession {
let Some(manager) = self.manager.upgrade() else {
return false;
};
if !manager.try_reserve_pending(
bytes,
items,
control,
class == PendingClass::Downlink,
) {
if !manager.try_reserve_pending(bytes, items, control, class == PendingClass::Downlink) {
return false;
}
state.pending_bytes += bytes;
@@ -301,13 +291,7 @@ impl WebSession {
control: bool,
) -> bool {
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_lane_frame_locked(
state,
frame_type,
stream_id,
payload,
control,
);
return self.queue_lane_frame_locked(state, frame_type, stream_id, payload, control);
}
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
let class = if control {
@@ -425,9 +409,7 @@ mod tests {
use std::net::SocketAddr;
use std::sync::Arc;
use crate::config::{
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
use crate::web::manager::WebProcessRuntime;
fn session() -> Arc<WebSession> {
+15 -18
View File
@@ -7,8 +7,8 @@ use subtle::ConstantTimeEq;
use super::uplink::{inbound_reservation, validate_batch};
use super::{
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame,
SessionState, WebSession, remember_closed,
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
WebSession, remember_closed,
};
use crate::config::{WebCarrier, WebLimitsConfig};
use crate::web::frame::{self, Frame, FrameType};
@@ -32,9 +32,11 @@ impl WebSession {
return Err(ManagerError::Protocol);
}
};
if frames.iter().copied().any(|value| {
value.stream_id != lane_id || frame::validate_client_shape(value).is_err()
}) {
if frames
.iter()
.copied()
.any(|value| value.stream_id != lane_id || frame::validate_client_shape(value).is_err())
{
self.close();
return Err(ManagerError::Protocol);
}
@@ -48,13 +50,17 @@ impl WebSession {
state.last_activity = Instant::now();
if !state.carrier_lanes.contains_key(&lane_id) {
if lane_id != 0
&& frames.first().is_some_and(|value| value.frame_type != FrameType::Open)
&& frames
.first()
.is_some_and(|value| value.frame_type != FrameType::Open)
&& only_late_frames(&frames)
{
return Ok(sequence);
}
if lane_id == 0
|| frames.first().is_none_or(|value| value.frame_type != FrameType::Open)
|| frames
.first()
.is_none_or(|value| value.frame_type != FrameType::Open)
{
drop(state);
self.close();
@@ -182,12 +188,7 @@ impl WebSession {
};
if let Some(batch) = acknowledged {
self.release_locked(&mut state, batch.data_bytes, batch.data_items, false);
self.release_locked(
&mut state,
batch.control_bytes,
batch.control_items,
true,
);
self.release_locked(&mut state, batch.control_bytes, batch.control_items, true);
if let Some(stream) = state.streams.get_mut(&lane_id)
&& let Some(waker) = stream.write_waker.take()
{
@@ -402,11 +403,7 @@ impl WebSession {
}
pub(super) fn remember_closed_locked(&self, state: &mut SessionState, stream_id: u32) {
let evicted = remember_closed(
state,
stream_id,
self.limits.max_tombstones_per_session,
);
let evicted = remember_closed(state, stream_id, self.limits.max_tombstones_per_session);
if self.carrier() != WebCarrier::HttpsLanes {
return;
}
+8 -7
View File
@@ -4,9 +4,7 @@ use std::sync::Arc;
use bytes::BytesMut;
use super::*;
use crate::config::{
WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::config::{WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
use crate::web::manager::WebProcessRuntime;
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
@@ -103,15 +101,18 @@ fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
state.carrier_lanes.insert(7, CarrierLane::new());
let encoded = frame::encode(FrameType::Close, 7, &[]);
let cost = encoded.len() + QUEUE_ITEM_COST;
state.carrier_lanes.get_mut(&7).unwrap().pending_frames.push_back(
QueuedFrame {
state
.carrier_lanes
.get_mut(&7)
.unwrap()
.pending_frames
.push_back(QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 7,
control: true,
cost,
},
);
});
state.pending_bytes = cost;
state.pending_items = 1;
state.pending_control_bytes = cost;
+9 -21
View File
@@ -138,12 +138,8 @@ impl WebSession {
FrameType::Open => {
let Some(peer_port) = self.reserve_stream_locked(state) else {
self.remember_closed_locked(state, value.stream_id);
if !self.queue_control_locked(
state,
FrameType::Close,
value.stream_id,
&[],
) {
if !self.queue_control_locked(state, FrameType::Close, value.stream_id, &[])
{
return false;
}
continue;
@@ -169,8 +165,8 @@ impl WebSession {
bytes: Bytes::copy_from_slice(value.payload),
offset: 0,
});
*unused_bytes = unused_bytes
.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
*unused_bytes =
unused_bytes.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
*unused_items = unused_items.saturating_sub(1);
if let Some(waker) = stream.read_waker.take() {
waker.wake();
@@ -256,8 +252,8 @@ pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool
}
continue;
}
let was_closed = state.closed_streams.contains(&value.stream_id)
|| closed.contains(&value.stream_id);
let was_closed =
state.closed_streams.contains(&value.stream_id) || closed.contains(&value.stream_id);
match value.frame_type {
FrameType::Open => {
if live.contains_key(&value.stream_id) || was_closed {
@@ -308,10 +304,7 @@ pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool
true
}
pub(super) fn inbound_reservation(
state: &SessionState,
frames: &[Frame<'_>],
) -> (usize, usize) {
pub(super) fn inbound_reservation(state: &SessionState, frames: &[Frame<'_>]) -> (usize, usize) {
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
let mut bytes = 0usize;
let mut items = 0usize;
@@ -338,9 +331,7 @@ mod tests {
use super::*;
use std::net::SocketAddr;
use crate::config::{
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
use crate::web::manager::WebProcessRuntime;
fn session() -> Arc<WebSession> {
@@ -383,10 +374,7 @@ mod tests {
let session = session();
let body = frame::encode(FrameType::Pong, 0, &[]);
session.up_active.store(true, Ordering::Release);
assert_eq!(
session.process_up(1, &body),
Err(ManagerError::Concurrent)
);
assert_eq!(session.process_up(1, &body), Err(ManagerError::Concurrent));
assert_eq!(session.state.lock().last_up_sequence, 0);
session.up_active.store(false, Ordering::Release);
assert_eq!(session.process_up(1, &body), Ok(1));