mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
Merge pull request #899 from telemt/flow-web-handshake
Flow web handshake
This commit is contained in:
@@ -2542,7 +2542,7 @@ Hinweis: Dieser Abschnitt akzeptiert auch den Legacy-Alias `[server.admin_api]`
|
|||||||
|
|
||||||
## web_client_ip_source (server.listeners)
|
## web_client_ip_source (server.listeners)
|
||||||
- **Einschränkungen / Validierung**: Die erste WEB-Implementierung unterstützt ausschließlich `"x_forwarded_for"`.
|
- **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)
|
## 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.
|
- **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. |
|
| `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. |
|
| `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]
|
# [web.vhosts.decoy]
|
||||||
|
|
||||||
|
|||||||
@@ -2542,7 +2542,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
|
|||||||
|
|
||||||
## web_client_ip_source (server.listeners)
|
## web_client_ip_source (server.listeners)
|
||||||
- **Constraints / validation**: Only `"x_forwarded_for"` is supported by the initial WEB implementation.
|
- **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)
|
## 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.
|
- **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. |
|
| `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. |
|
| `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]
|
# [web.vhosts.decoy]
|
||||||
|
|
||||||
|
|||||||
@@ -2468,7 +2468,7 @@
|
|||||||
|
|
||||||
## web_client_ip_source (server.listeners)
|
## web_client_ip_source (server.listeners)
|
||||||
- **Ограничения / валидация**: Первая реализация WEB поддерживает только `"x_forwarded_for"`.
|
- **Ограничения / валидация**: Первая реализация 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)
|
## web_trusted_proxy_cidrs (server.listeners)
|
||||||
- **Ограничения / валидация**: Непустой массив CIDR только для WEB. Сеть `/0` запрещена. Параметр недопустим для MTProxy-listener’а.
|
- **Ограничения / валидация**: Непустой массив CIDR только для WEB. Сеть `/0` запрещена. Параметр недопустим для MTProxy-listener’а.
|
||||||
@@ -2556,7 +2556,7 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут
|
|||||||
| `decoy` | таблица | да | `✔` | Обычный сайт для неаутентифицированного или некорректного трафика. |
|
| `decoy` | таблица | да | `✔` | Обычный сайт для неаутентифицированного или некорректного трафика. |
|
||||||
| `profiles` | массив таблиц | при включённом WEB | `✔` | Явные пользователи и client secret modes для этого hostname. |
|
| `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]
|
# [web.vhosts.decoy]
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS-C
|
|||||||
Telegram Desktop
|
Telegram Desktop
|
||||||
| HTTPS :443
|
| HTTPS :443
|
||||||
v
|
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
|
| unverschlüsseltes HTTP/1.1 in einem privaten Netz
|
||||||
v
|
v
|
||||||
Telemt-WEB-Listener
|
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.
|
- 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.
|
- `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.
|
- 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.
|
- 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:
|
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.
|
- 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`.
|
- 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
|
## 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.
|
Ö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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Erstprüfung
|
||||||
|
|
||||||
1. Starten Sie das neu erstellte Telemt-Binary mit der WEB-Konfiguration und prüfen Sie, dass der private Listener gebunden ist.
|
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.
|
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.
|
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.
|
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.
|
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 |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| `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`. |
|
| 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`. |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ WEB mode carries ordinary MTProxy streams through bounded HTTPS carriers compati
|
|||||||
Telegram Desktop
|
Telegram Desktop
|
||||||
| HTTPS :443
|
| HTTPS :443
|
||||||
v
|
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
|
| plain HTTP/1.1 on a private network
|
||||||
v
|
v
|
||||||
Telemt WEB listener
|
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.
|
- `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.
|
- `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.
|
- 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.
|
- 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:
|
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 normal decoy site, either a private HTTP origin or an immutable local directory snapshot.
|
||||||
- A compatible Telegram Desktop build with the `WEB` proxy type.
|
- 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
|
## 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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Initial verification
|
||||||
|
|
||||||
1. Start the rebuilt Telemt binary with the WEB configuration and confirm that the private listener is bound.
|
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.
|
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.
|
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.
|
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.
|
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 |
|
| 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. |
|
| 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`. |
|
| 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. |
|
| `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. |
|
| Telegram Desktop rejects the link | Omit the port, use a valid FQDN, port 443 externally, and only `plain` or `dd` secret mode. |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ WEB-режим переносит обычные MTProxy-потоки через
|
|||||||
Telegram Desktop
|
Telegram Desktop
|
||||||
| HTTPS :443
|
| HTTPS :443
|
||||||
v
|
v
|
||||||
NGINX или HAProxy (TLS termination, канонические Host и X-Forwarded-For)
|
NGINX или HAProxy (TLS termination, канонический Host и один адрес X-Forwarded-For)
|
||||||
| обычный HTTP/1.1 в приватной сети
|
| обычный HTTP/1.1 в приватной сети
|
||||||
v
|
v
|
||||||
WEB-listener Telemt
|
WEB-listener Telemt
|
||||||
@@ -30,6 +30,7 @@ WEB-listener Telemt
|
|||||||
- Поддерживаются 16-байтовые MTProxy-секреты `plain` и `dd`. FakeTLS-секреты `ee` в WEB-режиме не поддерживаются.
|
- Поддерживаются 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 не анонсируются.
|
- `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.
|
- 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.
|
- Внутренняя MTProxy-аутентификация ограничена пользователем и режимом секрета, выбранными профилем vhost. Некорректный внутренний handshake закрывает только свой logical stream и никогда не попадает в TCP masking path.
|
||||||
|
|
||||||
В WEB-ссылках Telegram Desktop нет порта, потому что клиент требует порт 443:
|
В WEB-ссылках Telegram Desktop нет порта, потому что клиент требует порт 443:
|
||||||
@@ -49,7 +50,7 @@ Telemt печатает ссылки для WEB-профилей, выбранн
|
|||||||
- Обычный decoy site: приватный HTTP origin либо immutable snapshot локального каталога.
|
- Обычный decoy site: приватный HTTP origin либо immutable snapshot локального каталога.
|
||||||
- Совместимая сборка Telegram Desktop с типом прокси `WEB`.
|
- Совместимая сборка 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
|
## Минимальная конфигурация 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.
|
Для `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.
|
- Отключите логирование request target и authorization на TLS-терминаторе либо используйте проверенный формат с редактированием. Raw queries содержат bridge capabilities, а `Authorization` — bootstrap или session bearer credentials.
|
||||||
- Сохраняйте один стабильный публичный адрес на vhost. Если DNS возвращает несколько ingress addresses, каждый deployment должен использовать адрес своего внешнего пути.
|
- Сохраняйте один стабильный публичный адрес на vhost. Если DNS возвращает несколько ingress addresses, каждый deployment должен использовать адрес своего внешнего пути.
|
||||||
- Bootstrap- и session-registries локальны для процесса. Для multi-process или multi-host upstream pool нужна affinity всего vhost: bridge GET, создание сессии, uplink, downlink и DELETE. Одному процессу Telemt дополнительная affinity не нужна.
|
- 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.
|
- Decoy входит в anti-probing contract. До распространения ссылок проверьте через публичный TLS endpoint его обычный ответ 404 и response timing.
|
||||||
|
|
||||||
## Первичная проверка
|
## Первичная проверка
|
||||||
|
|
||||||
1. Запустите пересобранный Telemt с WEB-конфигурацией и убедитесь, что приватный listener привязан.
|
1. Запустите пересобранный Telemt с WEB-конфигурацией и убедитесь, что приватный listener привязан.
|
||||||
2. Через публичный TLS endpoint проверьте, что `GET /`, неизвестный path и некорректный query `bridge` возвращают настроенный decoy site.
|
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 и установите соединение через прокси.
|
4. Импортируйте напечатанную ссылку `tg://webproxy` в целевую сборку Telegram Desktop и установите соединение через прокси.
|
||||||
5. Для `https-lanes` подтвердите согласование HTTP/2 на публичном connection и проверьте как минимум два одновременных logical streams; приватный hop к Telemt остаётся HTTP/1.1.
|
5. Для `https-lanes` подтвердите согласование HTTP/2 на публичном connection и проверьте как минимум два одновременных logical streams; приватный hop к Telemt остаётся HTTP/1.1.
|
||||||
6. Проверьте reconnect и как минимум один long poll длительнее 25 секунд, чтобы frontend timeouts не обрывали carrier.
|
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]` требуют перезапуска. |
|
| 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`. |
|
| 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. |
|
| `https-lanes` работает, но streams всё ещё блокируют друг друга | Проверьте согласование публичного HTTP/2, сохранение `X-Lane-ID` и достаточное число upstream connections TLS-терминатора для параллельных приватных HTTP/1.1 polls. |
|
||||||
| Telegram Desktop отклоняет ссылку | Не указывайте порт, используйте валидный FQDN, внешний порт 443 и только `plain` или `dd`. |
|
| Telegram Desktop отклоняет ссылку | Не указывайте порт, используйте валидный FQDN, внешний порт 443 и только `plain` или `dd`. |
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
let (client_secret, client_secret_len) =
|
let (client_secret, client_secret_len) =
|
||||||
client_secret(auth_entry.secret, profile.secret_mode);
|
client_secret(auth_entry.secret, profile.secret_mode);
|
||||||
let capability = derive_web_capability(
|
let capability =
|
||||||
&client_secret[..client_secret_len],
|
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
|
||||||
vhost.host.as_bytes(),
|
|
||||||
)?;
|
|
||||||
if !capabilities.insert(capability) {
|
if !capabilities.insert(capability) {
|
||||||
return Err(ProxyError::Config(format!(
|
return Err(ProxyError::Config(format!(
|
||||||
"WEB vhost `{}` contains profiles with the same client capability",
|
"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.
|
/// 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]> {
|
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret).map_err(|_| {
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
|
||||||
ProxyError::Config("WEB capability secret must not be empty".to_string())
|
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
|
||||||
})?;
|
|
||||||
mac.update(WEB_CAPABILITY_CONTEXT);
|
mac.update(WEB_CAPABILITY_CONTEXT);
|
||||||
mac.update(host);
|
mac.update(host);
|
||||||
Ok(mac.finalize().into_bytes().into())
|
Ok(mac.finalize().into_bytes().into())
|
||||||
@@ -159,13 +156,7 @@ fn build_decoy(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
WebDecoyConfig::StaticDirectory { directory, index } => {
|
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||||
let site = load_static_site(
|
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
|
||||||
directory,
|
|
||||||
index,
|
|
||||||
limits,
|
|
||||||
static_files,
|
|
||||||
static_bytes,
|
|
||||||
)?;
|
|
||||||
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
|
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,8 +230,7 @@ fn load_static_directory(
|
|||||||
})?;
|
})?;
|
||||||
if *total_files >= limits.max_static_files {
|
if *total_files >= limits.max_static_files {
|
||||||
return Err(ProxyError::Config(
|
return Err(ProxyError::Config(
|
||||||
"WEB static entries exceed process-wide web.limits.max_static_files"
|
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
|
||||||
.to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
*total_files += 1;
|
*total_files += 1;
|
||||||
@@ -317,8 +307,7 @@ fn load_static_directory(
|
|||||||
})?;
|
})?;
|
||||||
if *total_bytes > limits.max_static_bytes {
|
if *total_bytes > limits.max_static_bytes {
|
||||||
return Err(ProxyError::Config(
|
return Err(ProxyError::Config(
|
||||||
"WEB static snapshots exceed process-wide web.limits.max_static_bytes"
|
"WEB static snapshots exceed process-wide web.limits.max_static_bytes".to_string(),
|
||||||
.to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let relative = path.strip_prefix(root).map_err(|_| {
|
let relative = path.strip_prefix(root).map_err(|_| {
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate_limits(&config.web.limits)?;
|
validate_limits(&config.web.limits)?;
|
||||||
if config.web.carrier == WebCarrier::HttpsLanes
|
if config.web.carrier == WebCarrier::HttpsLanes && config.web.limits.max_http_handlers < 2 {
|
||||||
&& config.web.limits.max_http_handlers < 2
|
|
||||||
{
|
|
||||||
return config_error("web.carrier=https-lanes requires 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)?;
|
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_per_session", limits.max_streams_per_session),
|
||||||
("max_streams_global", limits.max_streams_global),
|
("max_streams_global", limits.max_streams_global),
|
||||||
("max_stream_handshakes", limits.max_stream_handshakes),
|
("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_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),
|
("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),
|
("control_bytes_global", limits.control_bytes_global),
|
||||||
("max_bootstraps_global", limits.max_bootstraps_global),
|
("max_bootstraps_global", limits.max_bootstraps_global),
|
||||||
("max_bootstraps_per_ip", limits.max_bootstraps_per_ip),
|
("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),
|
("max_stream_handshakes", limits.max_stream_handshakes),
|
||||||
] {
|
] {
|
||||||
if value > tokio::sync::Semaphore::MAX_PERMITS {
|
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 = [
|
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_bootstraps_burst", limits.new_bootstraps_burst),
|
||||||
("new_sessions_per_minute", limits.new_sessions_per_minute),
|
("new_sessions_per_minute", limits.new_sessions_per_minute),
|
||||||
("new_sessions_burst", limits.new_sessions_burst),
|
("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
|
let required_control_bytes_global = control_items_global
|
||||||
.checked_mul(control_frame_cost)
|
.checked_mul(control_frame_cost)
|
||||||
.ok_or_else(|| {
|
.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
|
if control_items_per_session >= limits.pending_items_per_session
|
||||||
|| control_items_global >= limits.pending_items_global
|
|| 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 hosts = HashSet::with_capacity(config.web.vhosts.len());
|
||||||
let mut profile_count = 0usize;
|
let mut profile_count = 0usize;
|
||||||
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
||||||
vhost.host = normalize_web_host(
|
vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?;
|
||||||
&vhost.host,
|
|
||||||
&format!("web.vhosts[{vhost_idx}].host"),
|
|
||||||
)?;
|
|
||||||
if !hosts.insert(vhost.host.clone()) {
|
if !hosts.insert(vhost.host.clone()) {
|
||||||
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
|
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
|
.max_streams_per_session
|
||||||
.unwrap_or(limits.max_streams_per_session);
|
.unwrap_or(limits.max_streams_per_session);
|
||||||
if profile.max_sessions == Some(0)
|
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 == Some(0)
|
||||||
|| profile
|
|| profile
|
||||||
.max_streams
|
.max_streams
|
||||||
|
|||||||
@@ -94,10 +94,7 @@ fn web_semaphore_limits_are_rejected_before_runtime_construction() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_ipv6_decoy_uses_a_valid_http_authority() {
|
fn web_ipv6_decoy_uses_a_valid_http_authority() {
|
||||||
let ipv6 = WEB_CONFIG.replace(
|
let ipv6 = WEB_CONFIG.replace("http://127.0.0.1:18081", "http://[::1]:18081");
|
||||||
"http://127.0.0.1:18081",
|
|
||||||
"http://[::1]:18081",
|
|
||||||
);
|
|
||||||
let config = load_config_from_temp_toml(&ipv6);
|
let config = load_config_from_temp_toml(&ipv6);
|
||||||
let runtime = config.web.runtime.expect("WEB runtime snapshot");
|
let runtime = config.web.runtime.expect("WEB runtime snapshot");
|
||||||
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
|
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ pub enum ListenerTransport {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WebClientIpSource {
|
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]
|
#[default]
|
||||||
XForwardedFor,
|
XForwardedFor,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,10 +359,7 @@ pub(crate) struct WebRuntimeProfile {
|
|||||||
/// Runtime-ready ordinary-site fallback.
|
/// Runtime-ready ordinary-site fallback.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum WebRuntimeDecoy {
|
pub(crate) enum WebRuntimeDecoy {
|
||||||
HttpUpstream {
|
HttpUpstream { addr: SocketAddr, authority: String },
|
||||||
addr: SocketAddr,
|
|
||||||
authority: String,
|
|
||||||
},
|
|
||||||
StaticDirectory(Arc<WebStaticSite>),
|
StaticDirectory(Arc<WebStaticSite>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ use tokio_util::task::TaskTracker;
|
|||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::crypto::SecureRandom;
|
use crate::crypto::SecureRandom;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
|
use crate::proxy::authenticated::ClientRuntimeDeps;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxy::route_mode::RelayRouteMode;
|
use crate::proxy::route_mode::RelayRouteMode;
|
||||||
use crate::proxy::route_mode::RouteRuntimeController;
|
use crate::proxy::route_mode::RouteRuntimeController;
|
||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
use crate::proxy::authenticated::ClientRuntimeDeps;
|
|
||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
use crate::stats::{ReplayChecker, Stats};
|
use crate::stats::{ReplayChecker, Stats};
|
||||||
use crate::stream::BufferPool;
|
use crate::stream::BufferPool;
|
||||||
|
|||||||
@@ -301,12 +301,7 @@ impl ListenerSlot {
|
|||||||
);
|
);
|
||||||
tokio::time::timeout(connection_stop_timeout, self.connections.wait())
|
tokio::time::timeout(connection_stop_timeout, self.connections.wait())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| format!("listener {} connection shutdown timed out", self.spec.addr))?;
|
||||||
format!(
|
|
||||||
"listener {} connection shutdown timed out",
|
|
||||||
self.spec.addr
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
|
||||||
use crate::config::ListenerTransport;
|
use crate::config::ListenerTransport;
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
use crate::maestro::generation::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
|
||||||
use super::accept::ListenerSlot;
|
use super::accept::ListenerSlot;
|
||||||
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
|
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
|
||||||
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
use crate::web::manager::WebProcessRuntime;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use super::unix::UnixAcceptHandle;
|
use super::unix::UnixAcceptHandle;
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
/// Process-owned listener inventory and accept-task lifecycle controller.
|
/// Process-owned listener inventory and accept-task lifecycle controller.
|
||||||
pub(crate) struct ListenerManager {
|
pub(crate) struct ListenerManager {
|
||||||
@@ -54,11 +54,7 @@ impl ListenerManager {
|
|||||||
let addr = listener.spec.addr;
|
let addr = listener.spec.addr;
|
||||||
slots.insert(
|
slots.insert(
|
||||||
addr,
|
addr,
|
||||||
ListenerSlot::start(
|
ListenerSlot::start(listener, active_runtime.clone(), web_runtime.clone()),
|
||||||
listener,
|
|
||||||
active_runtime.clone(),
|
|
||||||
web_runtime.clone(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -103,7 +99,9 @@ impl ListenerManager {
|
|||||||
.map(|(addr, spec)| (*addr, spec.clone()))
|
.map(|(addr, spec)| (*addr, spec.clone()))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
if web_inventory_changed {
|
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 current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
|
||||||
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
|
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
|
||||||
|
|||||||
@@ -406,8 +406,7 @@ pub(crate) fn resolve_reload_config(
|
|||||||
fields.push("logging".to_string());
|
fields.push("logging".to_string());
|
||||||
effective.logging = old.logging.clone();
|
effective.logging = old.logging.clone();
|
||||||
}
|
}
|
||||||
if serde_json::to_value(&old.web.limits).ok()
|
if serde_json::to_value(&old.web.limits).ok() != serde_json::to_value(&desired.web.limits).ok()
|
||||||
!= serde_json::to_value(&desired.web.limits).ok()
|
|
||||||
{
|
{
|
||||||
fields.push("web.limits".to_string());
|
fields.push("web.limits".to_string());
|
||||||
effective.web.limits = old.web.limits.clone();
|
effective.web.limits = old.web.limits.clone();
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ use crate::error::{ProxyError, Result};
|
|||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack;
|
use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack;
|
||||||
use crate::proxy::handshake::HandshakeSuccess;
|
use crate::proxy::handshake::HandshakeSuccess;
|
||||||
use crate::proxy::middle_relay::{
|
use crate::proxy::middle_relay::{handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack};
|
||||||
handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack,
|
|
||||||
};
|
|
||||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
|
|||||||
+2
-4
@@ -43,9 +43,7 @@ use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_pro
|
|||||||
|
|
||||||
use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated};
|
use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxy::authenticated::{
|
use crate::proxy::authenticated::{UserConnectionReservation, acquire_user_connection_reservation};
|
||||||
UserConnectionReservation, acquire_user_connection_reservation,
|
|
||||||
};
|
|
||||||
use crate::proxy::handshake::{
|
use crate::proxy::handshake::{
|
||||||
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
|
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
|
||||||
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
|
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
|
||||||
@@ -53,9 +51,9 @@ use crate::proxy::handshake::{
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
|
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
|
||||||
use crate::proxy::masking::handle_bad_client_with_shared;
|
use crate::proxy::masking::handle_bad_client_with_shared;
|
||||||
use crate::proxy::route_mode::RouteRuntimeController;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxy::route_mode::RelayRouteMode;
|
use crate::proxy::route_mode::RelayRouteMode;
|
||||||
|
use crate::proxy::route_mode::RouteRuntimeController;
|
||||||
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
||||||
|
|
||||||
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
|
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate};
|
|||||||
pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState};
|
pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub use self::mtproto::handle_mtproto_handshake;
|
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(crate) use self::mtproto::handle_mtproto_handshake_for_web_user;
|
||||||
|
pub use self::mtproto::handle_mtproto_handshake_with_shared;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
|
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
|
||||||
pub use self::session::HandshakeSuccess;
|
pub use self::session::HandshakeSuccess;
|
||||||
|
|||||||
@@ -274,12 +274,7 @@ pub(super) fn mode_enabled_for_proto(
|
|||||||
proto_tag: ProtoTag,
|
proto_tag: ProtoTag,
|
||||||
is_tls: bool,
|
is_tls: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
mode_enabled_for_proto_with_policy(
|
mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, MtprotoModePolicy::Configured)
|
||||||
config,
|
|
||||||
proto_tag,
|
|
||||||
is_tls,
|
|
||||||
MtprotoModePolicy::Configured,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mode_enabled_for_proto_with_policy(
|
fn mode_enabled_for_proto_with_policy(
|
||||||
|
|||||||
@@ -203,15 +203,23 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut matched = exact_user_id.is_some_and(|user_id| try_user_id!(user_id));
|
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);
|
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);
|
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);
|
matched = try_user_id!(user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-12
@@ -114,8 +114,13 @@ function joinPending(values,lane){
|
|||||||
return {body:joined.buffer,total,count};
|
return {body:joined.buffer,total,count};
|
||||||
}
|
}
|
||||||
function retryAfterMs(response){
|
function retryAfterMs(response){
|
||||||
const value=Number(response.headers.get('Retry-After'));
|
const header=response.headers.get('Retry-After');
|
||||||
return Number.isFinite(value)&&value>=0?Math.min(value*1000,30000):0;
|
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){
|
async function request(path,makeOptions){
|
||||||
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
||||||
@@ -142,13 +147,11 @@ async function createSession(first){
|
|||||||
try{
|
try{
|
||||||
status('connecting');
|
status('connecting');
|
||||||
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
|
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';
|
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}
|
if(closed){deleteSession();return}
|
||||||
const welcome=await response.arrayBuffer();
|
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');
|
port.postMessage(welcome,[welcome]);status('connected');
|
||||||
if(carrier==='https-lanes')ensureLane(0);
|
if(carrier==='https-lanes')ensureLane(0);
|
||||||
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
|
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');
|
if(response.status!==200)throw new Error('lane downlink rejected');
|
||||||
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
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;
|
if(closed)return;
|
||||||
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
|
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}
|
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;
|
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
|
||||||
activatePort(event.ports[0]);
|
activatePort(event.ports[0]);
|
||||||
});
|
},{once:false});
|
||||||
const androidBridge=globalThis.TelegramWebProxy;
|
const androidBridge=globalThis.TelegramWebProxy;
|
||||||
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
|
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
|
||||||
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
|
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})};
|
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}));
|
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("X-Up-Seq"));
|
||||||
assert!(page.body.contains("carrier='https-lanes'"));
|
assert!(page.body.contains("carrier='https-lanes'"));
|
||||||
assert!(page.body.contains("X-Lane-ID"));
|
assert!(page.body.contains("X-Lane-ID"));
|
||||||
assert!(page
|
assert!(page.body.contains("const when=Date.parse(header)"));
|
||||||
.content_security_policy
|
assert!(page.body.contains("},{once:false});"));
|
||||||
.contains("frame-ancestors http://127.0.0.1:*"));
|
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
@@ -98,15 +98,10 @@ pub(crate) fn parse_all<'a>(
|
|||||||
return Err(FrameError::Incomplete);
|
return Err(FrameError::Incomplete);
|
||||||
}
|
}
|
||||||
let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?;
|
let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?;
|
||||||
let stream_id = u32::from(remaining[1]) << 16
|
let stream_id =
|
||||||
| u32::from(remaining[2]) << 8
|
u32::from(remaining[1]) << 16 | u32::from(remaining[2]) << 8 | u32::from(remaining[3]);
|
||||||
| u32::from(remaining[3]);
|
let payload_len =
|
||||||
let payload_len = u32::from_be_bytes([
|
u32::from_be_bytes([remaining[4], remaining[5], remaining[6], remaining[7]]) as usize;
|
||||||
remaining[4],
|
|
||||||
remaining[5],
|
|
||||||
remaining[6],
|
|
||||||
remaining[7],
|
|
||||||
]) as usize;
|
|
||||||
if payload_len > limits.max_frame_payload_bytes {
|
if payload_len > limits.max_frame_payload_bytes {
|
||||||
return Err(FrameError::PayloadLimit);
|
return Err(FrameError::PayloadLimit);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-30
@@ -34,9 +34,9 @@ mod request;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
use decoy::serve_decoy;
|
|
||||||
use activity::{ActivityBody, RequestActivity};
|
use activity::{ActivityBody, RequestActivity};
|
||||||
use body::{CollectBodyError, CollectedBody, collect_body};
|
use body::{CollectBodyError, CollectedBody, collect_body};
|
||||||
|
use decoy::serve_decoy;
|
||||||
use request::{
|
use request::{
|
||||||
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
|
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
|
||||||
canonical_u64_header, client_ip, match_profile,
|
canonical_u64_header, client_ip, match_profile,
|
||||||
@@ -84,10 +84,7 @@ pub(crate) async fn serve_connection(
|
|||||||
} else {
|
} else {
|
||||||
service_unavailable()
|
service_unavailable()
|
||||||
};
|
};
|
||||||
let response = response.map(|body| {
|
let response = response.map(|body| ActivityBody::new(body, activity).boxed_unsync());
|
||||||
ActivityBody::new(body, activity)
|
|
||||||
.boxed_unsync()
|
|
||||||
});
|
|
||||||
Ok::<_, Infallible>(response)
|
Ok::<_, Infallible>(response)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -174,12 +171,7 @@ async fn handle_root(
|
|||||||
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
|
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
|
||||||
return serve_decoy(request, vhost, false, &runtime).await;
|
return serve_decoy(request, vhost, false, &runtime).await;
|
||||||
};
|
};
|
||||||
let Some(client_ip) = client_ip(
|
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
|
||||||
&request,
|
|
||||||
peer,
|
|
||||||
client_ip_source,
|
|
||||||
trusted_proxy_cidrs,
|
|
||||||
) else {
|
|
||||||
strip_query(&mut request);
|
strip_query(&mut request);
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
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) {
|
if request.uri().query().is_some() || request.headers().contains_key(header::COOKIE) {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
}
|
}
|
||||||
let Some(client_ip) = client_ip(
|
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
|
||||||
&request,
|
|
||||||
peer,
|
|
||||||
client_ip_source,
|
|
||||||
trusted_proxy_cidrs,
|
|
||||||
) else {
|
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
};
|
};
|
||||||
let Some(token_hash) = bearer_token_hash(&request) else {
|
let Some(token_hash) = bearer_token_hash(&request) else {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
};
|
};
|
||||||
match request.uri().path() {
|
match request.uri().path() {
|
||||||
"/api/v1/session" => {
|
"/api/v1/session" => handle_session(request, runtime, vhost, token_hash, client_ip).await,
|
||||||
handle_session(request, runtime, vhost, token_hash, client_ip).await
|
|
||||||
}
|
|
||||||
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
|
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
|
||||||
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
|
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
|
||||||
_ => serve_decoy(request, vhost, true, &runtime).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 {
|
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
|
||||||
return serve_decoy(request, vhost, true, &runtime).await;
|
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 {
|
let CollectedBody {
|
||||||
request,
|
request,
|
||||||
body,
|
body,
|
||||||
@@ -510,10 +500,7 @@ fn bad_gateway() -> HttpResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn generic_not_found() -> HttpResponse {
|
fn generic_not_found() -> HttpResponse {
|
||||||
full_response(
|
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Bytes::from_static(b"not found\n"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||||
@@ -523,11 +510,7 @@ fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
|||||||
.boxed_unsync();
|
.boxed_unsync();
|
||||||
let mut response = Response::new(body);
|
let mut response = Response::new(body);
|
||||||
*response.status_mut() = status;
|
*response.status_mut() = status;
|
||||||
insert_header(
|
insert_header(&mut response, header::CONTENT_LENGTH, &length.to_string());
|
||||||
&mut response,
|
|
||||||
header::CONTENT_LENGTH,
|
|
||||||
&length.to_string(),
|
|
||||||
);
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ pub(super) struct ActivityBody {
|
|||||||
impl ActivityBody {
|
impl ActivityBody {
|
||||||
/// Binds one response body to its request activity guard.
|
/// Binds one response body to its request activity guard.
|
||||||
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
|
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
|
||||||
Self {
|
Self { inner, activity }
|
||||||
inner,
|
|
||||||
activity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::{BodyExt, Empty, Limited};
|
use http_body_util::{BodyExt, Empty, Limited};
|
||||||
use hyper::body::{Body as _, Incoming};
|
|
||||||
use hyper::Request;
|
use hyper::Request;
|
||||||
|
use hyper::body::{Body as _, Incoming};
|
||||||
|
|
||||||
use crate::web::manager::WebProcessRuntime;
|
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 {
|
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
|
||||||
return Err(CollectBodyError::Limit);
|
return Err(CollectBodyError::Limit);
|
||||||
};
|
};
|
||||||
let body_timeout = Duration::from_secs(
|
let body_timeout =
|
||||||
runtime
|
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs);
|
||||||
.active_generation()
|
|
||||||
.config()
|
|
||||||
.web
|
|
||||||
.timeouts
|
|
||||||
.body_secs,
|
|
||||||
);
|
|
||||||
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
||||||
Ok(Ok(body)) => body.to_bytes(),
|
Ok(Ok(body)) => body.to_bytes(),
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
+13
-16
@@ -11,8 +11,7 @@ use hyper_util::rt::TokioIo;
|
|||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
|
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found, insert_header,
|
||||||
insert_header,
|
|
||||||
};
|
};
|
||||||
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
|
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
|
||||||
use crate::web::manager::WebProcessRuntime;
|
use crate::web::manager::WebProcessRuntime;
|
||||||
@@ -128,14 +127,13 @@ fn static_entry<B>(
|
|||||||
header::X_CONTENT_TYPE_OPTIONS,
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
HeaderValue::from_static("nosniff"),
|
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
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_static_path<'a>(
|
fn resolve_static_path<'a>(path: &str, site: &'a crate::config::WebStaticSite) -> Option<&'a str> {
|
||||||
path: &str,
|
|
||||||
site: &'a crate::config::WebStaticSite,
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
if !path.starts_with('/')
|
if !path.starts_with('/')
|
||||||
|| path.contains('\\')
|
|| path.contains('\\')
|
||||||
|| path.contains("//")
|
|| path.contains("//")
|
||||||
@@ -151,7 +149,10 @@ fn resolve_static_path<'a>(
|
|||||||
path
|
path
|
||||||
};
|
};
|
||||||
if site.assets.contains_key(route) {
|
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") {
|
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
|
||||||
return Some("/favicon.svg");
|
return Some("/favicon.svg");
|
||||||
@@ -198,20 +199,16 @@ async fn proxy_to_upstream(
|
|||||||
.max_header_bytes;
|
.max_header_bytes;
|
||||||
let mut builder = hyper::client::conn::http1::Builder::new();
|
let mut builder = hyper::client::conn::http1::Builder::new();
|
||||||
builder.max_buf_size(max_header_bytes);
|
builder.max_buf_size(max_header_bytes);
|
||||||
let (mut sender, connection) = match tokio::time::timeout(
|
let (mut sender, connection) =
|
||||||
header_timeout,
|
match tokio::time::timeout(header_timeout, builder.handshake(TokioIo::new(stream))).await {
|
||||||
builder.handshake(TokioIo::new(stream)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(parts)) => parts,
|
Ok(Ok(parts)) => parts,
|
||||||
_ => return bad_gateway(),
|
_ => return bad_gateway(),
|
||||||
};
|
};
|
||||||
runtime.spawn_auxiliary(async move {
|
runtime.spawn_auxiliary(async move {
|
||||||
let _ = connection.await;
|
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,
|
Ok(Ok(response)) => response,
|
||||||
_ => return bad_gateway(),
|
_ => return bad_gateway(),
|
||||||
};
|
};
|
||||||
|
|||||||
+60
-30
@@ -2,15 +2,13 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use hyper::header;
|
|
||||||
use hyper::Request;
|
use hyper::Request;
|
||||||
|
use hyper::header;
|
||||||
use ipnetwork::IpNetwork;
|
use ipnetwork::IpNetwork;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost};
|
||||||
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
|
|
||||||
};
|
|
||||||
use crate::web::manager::TokenHash;
|
use crate::web::manager::TokenHash;
|
||||||
|
|
||||||
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
|
/// 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;
|
return None;
|
||||||
}
|
}
|
||||||
let host = value.strip_suffix(":443").unwrap_or(value);
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
Some(host)
|
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>(
|
pub(super) fn client_ip<B>(
|
||||||
request: &Request<B>,
|
request: &Request<B>,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
@@ -51,16 +48,17 @@ pub(super) fn client_ip<B>(
|
|||||||
};
|
};
|
||||||
let values = request.headers().get_all(header_name);
|
let values = request.headers().get_all(header_name);
|
||||||
let mut values = values.iter();
|
let mut values = values.iter();
|
||||||
let value = values.next()?.to_str().ok()?;
|
let Some(value) = values.next() else {
|
||||||
if values.next().is_some()
|
return Some(peer.ip());
|
||||||
|| value.is_empty()
|
};
|
||||||
|| value.trim() != value
|
let value = value.to_str().ok()?;
|
||||||
|| value.contains(',')
|
if values.next().is_some() || value.trim() != value || value.contains(',') {
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ip = value.parse::<IpAddr>().ok()?;
|
if value.is_empty() {
|
||||||
(ip.to_string() == value).then_some(ip)
|
return Some(peer.ip());
|
||||||
|
}
|
||||||
|
value.parse::<IpAddr>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes an exact canonical bridge query without allocating credential strings.
|
/// 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);
|
return (candidate, false);
|
||||||
}
|
}
|
||||||
let mut decoded = [0u8; 32];
|
let mut decoded = [0u8; 32];
|
||||||
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
let Ok(decoded_len) =
|
||||||
.decode_slice(value, &mut decoded)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded)
|
||||||
else {
|
else {
|
||||||
return (candidate, false);
|
return (candidate, false);
|
||||||
};
|
};
|
||||||
let mut canonical = [0u8; 43];
|
let mut canonical = [0u8; 43];
|
||||||
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
let Ok(encoded_len) =
|
||||||
.encode_slice(decoded, &mut canonical)
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical)
|
||||||
else {
|
else {
|
||||||
return (candidate, false);
|
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 values = request.headers().get_all(header::AUTHORIZATION);
|
||||||
let mut values = values.iter();
|
let mut values = values.iter();
|
||||||
let value = values.next()?.to_str().ok()?;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let token = value.strip_prefix("Bearer ")?;
|
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.
|
/// Parses one canonical unsigned decimal carrier sequence header.
|
||||||
pub(super) fn canonical_u64_header<B>(
|
pub(super) fn canonical_u64_header<B>(request: &Request<B>, name: &'static str) -> Option<u64> {
|
||||||
request: &Request<B>,
|
|
||||||
name: &'static str,
|
|
||||||
) -> Option<u64> {
|
|
||||||
let values = request.headers().get_all(name);
|
let values = request.headers().get_all(name);
|
||||||
let mut values = values.iter();
|
let mut values = values.iter();
|
||||||
let value = values.next()?.to_str().ok()?;
|
let value = values.next()?.to_str().ok()?;
|
||||||
@@ -176,16 +170,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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()
|
let request = Request::builder()
|
||||||
.header(header::HOST, "proxy.example.com:443")
|
.header(header::HOST, "proxy.example.com:443")
|
||||||
.header("x-forwarded-for", "192.0.2.10")
|
.header("x-forwarded-for", "192.0.2.10")
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
|
||||||
canonical_request_host(&request),
|
|
||||||
Some("proxy.example.com")
|
|
||||||
);
|
|
||||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client_ip(
|
client_ip(
|
||||||
@@ -197,6 +188,45 @@ mod tests {
|
|||||||
Some("192.0.2.10".parse().unwrap())
|
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()
|
let uppercase = Request::builder()
|
||||||
.header(header::HOST, "Proxy.Example.com")
|
.header(header::HOST, "Proxy.Example.com")
|
||||||
.body(())
|
.body(())
|
||||||
|
|||||||
+139
-21
@@ -122,15 +122,6 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
|
|||||||
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
|
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
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!(
|
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"
|
"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");
|
let session = response_header(create_headers, "x-session-token");
|
||||||
assert_eq!(session.len(), 43);
|
assert_eq!(session.len(), 43);
|
||||||
|
|
||||||
let replacement = test_runtime_generation(
|
let replacement =
|
||||||
2,
|
test_runtime_generation(2, runtime_config(capability, WebCarrier::HttpsLanes));
|
||||||
runtime_config(capability, WebCarrier::HttpsLanes),
|
|
||||||
);
|
|
||||||
active_runtime.store(Arc::clone(&replacement));
|
active_runtime.store(Arc::clone(&replacement));
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||||
let retry_response = request(&listener, &runtime, create_retry).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();
|
.into_bytes();
|
||||||
let next_root_response = request(&listener, &runtime, next_root).await;
|
let next_root_response = request(&listener, &runtime, next_root).await;
|
||||||
let (_, next_root_body) = split_response(&next_root_response);
|
let (_, next_root_body) = split_response(&next_root_response);
|
||||||
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
|
assert!(
|
||||||
assert!(next_root_body
|
next_root_body
|
||||||
|
.windows(11)
|
||||||
|
.any(|value| value == b"bootstrap='")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
next_root_body
|
||||||
.windows(21)
|
.windows(21)
|
||||||
.any(|value| value == b"carrier='https-lanes'"));
|
.any(|value| value == b"carrier='https-lanes'")
|
||||||
|
);
|
||||||
|
|
||||||
let close = format!(
|
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"
|
"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;
|
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]
|
#[tokio::test]
|
||||||
async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
|
async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
|
||||||
let capability = [9u8; 32];
|
let capability = [9u8; 32];
|
||||||
let generation = test_runtime_generation(
|
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::HttpsLanes));
|
||||||
1,
|
|
||||||
runtime_config(capability, WebCarrier::HttpsLanes),
|
|
||||||
);
|
|
||||||
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||||
let runtime = WebProcessRuntime::start(active_runtime);
|
let runtime = WebProcessRuntime::start(active_runtime);
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
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);
|
create.extend_from_slice(&hello);
|
||||||
let create_response = request(&listener, &runtime, create).await;
|
let create_response = request(&listener, &runtime, create).await;
|
||||||
let (create_headers, _) = split_response(&create_response);
|
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 session = response_header(create_headers, "x-session-token").to_string();
|
||||||
|
|
||||||
let pong = frame::encode(FrameType::Pong, 0, &[]);
|
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);
|
let (uplink_headers, _) = split_response(&uplink_response);
|
||||||
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
|
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
|
||||||
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
|
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!(
|
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",
|
"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
@@ -89,9 +89,7 @@ pub(crate) struct WebProcessRuntime {
|
|||||||
|
|
||||||
impl WebProcessRuntime {
|
impl WebProcessRuntime {
|
||||||
/// Starts one process-scoped manager using immutable allocation ceilings.
|
/// Starts one process-scoped manager using immutable allocation ceilings.
|
||||||
pub(crate) fn start(
|
pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> {
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
|
||||||
) -> Arc<Self> {
|
|
||||||
let limits = active_runtime.load().config().web.limits.clone();
|
let limits = active_runtime.load().config().web.limits.clone();
|
||||||
let runtime = Arc::new(Self {
|
let runtime = Arc::new(Self {
|
||||||
active_runtime,
|
active_runtime,
|
||||||
@@ -169,9 +167,7 @@ impl WebProcessRuntime {
|
|||||||
|
|
||||||
/// Reserves one logical stream in the inner MTProxy handshake phase.
|
/// Reserves one logical stream in the inner MTProxy handshake phase.
|
||||||
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
|
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
let permit = Arc::clone(&self.stream_handshakes)
|
let permit = Arc::clone(&self.stream_handshakes).try_acquire_owned().ok();
|
||||||
.try_acquire_owned()
|
|
||||||
.ok();
|
|
||||||
if permit.is_none() {
|
if permit.is_none() {
|
||||||
self.record_stream_rejected();
|
self.record_stream_rejected();
|
||||||
}
|
}
|
||||||
@@ -215,7 +211,7 @@ impl WebProcessRuntime {
|
|||||||
Some((reader, body))
|
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(
|
pub(crate) fn issue_bootstrap(
|
||||||
&self,
|
&self,
|
||||||
profile: Arc<WebRuntimeProfile>,
|
profile: Arc<WebRuntimeProfile>,
|
||||||
@@ -229,17 +225,18 @@ impl WebProcessRuntime {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|runtime| matching_profile(runtime, &profile))
|
.and_then(|runtime| matching_profile(runtime, &profile))
|
||||||
.ok_or(ManagerError::Authentication)?;
|
.ok_or(ManagerError::Authentication)?;
|
||||||
if !config.web.enabled
|
if !config.web.enabled || !generation.proxy_shared.is_user_enabled(&profile.user) {
|
||||||
|| profile.public_addr.is_ipv4() != client_ip.is_ipv4()
|
|
||||||
|| !generation.proxy_shared.is_user_enabled(&profile.user)
|
|
||||||
{
|
|
||||||
return Err(ManagerError::Closed);
|
return Err(ManagerError::Closed);
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
remove_expired_locked(&mut state, now);
|
remove_expired_locked(&mut state, now);
|
||||||
if state.closed
|
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
|
>= self.limits.max_bootstraps_per_ip
|
||||||
|| !allow_rate(
|
|| !allow_rate(
|
||||||
&mut state.bootstrap_rate,
|
&mut state.bootstrap_rate,
|
||||||
@@ -264,7 +261,6 @@ impl WebProcessRuntime {
|
|||||||
state.bootstraps.insert(
|
state.bootstraps.insert(
|
||||||
hash,
|
hash,
|
||||||
Bootstrap {
|
Bootstrap {
|
||||||
generation_id: generation.id,
|
|
||||||
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
|
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
|
||||||
issued_at: now,
|
issued_at: now,
|
||||||
issuance_ip: client_ip,
|
issuance_ip: client_ip,
|
||||||
@@ -281,15 +277,12 @@ impl WebProcessRuntime {
|
|||||||
|
|
||||||
/// Checks whether a bootstrap token is live before reading a request body.
|
/// Checks whether a bootstrap token is live before reading a request body.
|
||||||
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
|
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
|
||||||
let generation_id = self.active_runtime.load().id;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let state = self.state.lock();
|
let state = self.state.lock();
|
||||||
state.bootstraps.get(&hash).is_some_and(|entry| {
|
state
|
||||||
entry.profile.host == host
|
.bootstraps
|
||||||
&& now <= entry.expires_at
|
.get(&hash)
|
||||||
&& (entry.generation_id == generation_id
|
.is_some_and(|entry| entry.profile.host == host && now <= entry.expires_at)
|
||||||
|| entry.used && entry.session.is_some())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a session exactly once or replays the original successful result.
|
/// Creates a session exactly once or replays the original successful result.
|
||||||
@@ -320,18 +313,12 @@ impl WebProcessRuntime {
|
|||||||
if !digest_matches {
|
if !digest_matches {
|
||||||
return Err(ManagerError::Authentication);
|
return Err(ManagerError::Authentication);
|
||||||
}
|
}
|
||||||
let session = entry
|
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
|
||||||
.session
|
|
||||||
.as_ref()
|
|
||||||
.ok_or(ManagerError::Authentication)?;
|
|
||||||
return Ok(CreateResult {
|
return Ok(CreateResult {
|
||||||
token: entry.session_token.as_str().to_owned(),
|
token: entry.session_token.as_str().to_owned(),
|
||||||
carrier: session.carrier(),
|
carrier: session.carrier(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if entry.generation_id != generation.id {
|
|
||||||
return Err(ManagerError::Authentication);
|
|
||||||
}
|
|
||||||
if state.closed || !config.web.enabled {
|
if state.closed || !config.web.enabled {
|
||||||
return Err(ManagerError::Closed);
|
return Err(ManagerError::Closed);
|
||||||
}
|
}
|
||||||
@@ -340,10 +327,7 @@ impl WebProcessRuntime {
|
|||||||
.runtime
|
.runtime
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|runtime| matching_profile(runtime, &entry.profile))
|
.and_then(|runtime| matching_profile(runtime, &entry.profile))
|
||||||
.filter(|profile| {
|
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
|
||||||
profile.public_addr.is_ipv4() == client_ip.is_ipv4()
|
|
||||||
&& generation.proxy_shared.is_user_enabled(&profile.user)
|
|
||||||
})
|
|
||||||
.ok_or(ManagerError::Authentication)?;
|
.ok_or(ManagerError::Authentication)?;
|
||||||
let profile_key = profile_key(&profile);
|
let profile_key = profile_key(&profile);
|
||||||
if state.sessions.len() >= self.limits.max_sessions_global
|
if state.sessions.len() >= self.limits.max_sessions_global
|
||||||
@@ -461,14 +445,11 @@ impl WebProcessRuntime {
|
|||||||
let fits = if control {
|
let fits = if control {
|
||||||
bytes <= self.limits.control_bytes_global
|
bytes <= self.limits.control_bytes_global
|
||||||
&& items <= control_item_reserve
|
&& items <= control_item_reserve
|
||||||
&& state.pending_bytes
|
&& state.pending_bytes <= self.limits.pending_bytes_global.saturating_sub(bytes)
|
||||||
<= self.limits.pending_bytes_global.saturating_sub(bytes)
|
&& state.pending_items <= self.limits.pending_items_global.saturating_sub(items)
|
||||||
&& state.pending_items
|
|
||||||
<= self.limits.pending_items_global.saturating_sub(items)
|
|
||||||
&& state.pending_control_bytes
|
&& state.pending_control_bytes
|
||||||
<= self.limits.control_bytes_global.saturating_sub(bytes)
|
<= self.limits.control_bytes_global.saturating_sub(bytes)
|
||||||
&& state.pending_control_items
|
&& state.pending_control_items <= control_item_reserve.saturating_sub(items)
|
||||||
<= control_item_reserve.saturating_sub(items)
|
|
||||||
} else {
|
} else {
|
||||||
let data_bytes = state
|
let data_bytes = state
|
||||||
.pending_bytes
|
.pending_bytes
|
||||||
@@ -477,10 +458,7 @@ impl WebProcessRuntime {
|
|||||||
.pending_items
|
.pending_items
|
||||||
.saturating_sub(state.pending_control_items);
|
.saturating_sub(state.pending_control_items);
|
||||||
let (byte_limit, item_limit) = if downlink {
|
let (byte_limit, item_limit) = if downlink {
|
||||||
let uplink_bytes = self
|
let uplink_bytes = self.limits.max_body_bytes.saturating_add(
|
||||||
.limits
|
|
||||||
.max_body_bytes
|
|
||||||
.saturating_add(
|
|
||||||
self.limits
|
self.limits
|
||||||
.max_frames_per_body
|
.max_frames_per_body
|
||||||
.saturating_mul(crate::web::session::QUEUE_ITEM_COST),
|
.saturating_mul(crate::web::session::QUEUE_ITEM_COST),
|
||||||
@@ -544,5 +522,4 @@ impl WebProcessRuntime {
|
|||||||
fn record_limit_hit(&self) {
|
fn record_limit_hit(&self) {
|
||||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{allocate_stream_port, allow_rate, decrement_map, release_stream_port};
|
||||||
allocate_stream_port, allow_rate, decrement_map, release_stream_port,
|
|
||||||
};
|
|
||||||
use super::{ProfileKey, WebProcessRuntime};
|
use super::{ProfileKey, WebProcessRuntime};
|
||||||
|
|
||||||
impl WebProcessRuntime {
|
impl WebProcessRuntime {
|
||||||
@@ -43,10 +41,7 @@ impl WebProcessRuntime {
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
state.streams_live += 1;
|
state.streams_live += 1;
|
||||||
*state
|
*state.streams_per_profile.entry(profile_key).or_insert(0) += 1;
|
||||||
.streams_per_profile
|
|
||||||
.entry(profile_key)
|
|
||||||
.or_insert(0) += 1;
|
|
||||||
self.streams_opened.fetch_add(1, Ordering::Relaxed);
|
self.streams_opened.fetch_add(1, Ordering::Relaxed);
|
||||||
Some(peer_port)
|
Some(peer_port)
|
||||||
}
|
}
|
||||||
@@ -105,12 +100,7 @@ mod tests {
|
|||||||
let downlink_bytes = data_bytes - uplink_bytes;
|
let downlink_bytes = data_bytes - uplink_bytes;
|
||||||
let downlink_items = data_items - runtime.limits.max_frames_per_body;
|
let downlink_items = data_items - runtime.limits.max_frames_per_body;
|
||||||
|
|
||||||
assert!(runtime.try_reserve_pending(
|
assert!(runtime.try_reserve_pending(downlink_bytes, downlink_items, false, true,));
|
||||||
downlink_bytes,
|
|
||||||
downlink_items,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
assert!(runtime.try_reserve_pending(
|
assert!(runtime.try_reserve_pending(
|
||||||
uplink_bytes,
|
uplink_bytes,
|
||||||
runtime.limits.max_frames_per_body,
|
runtime.limits.max_frames_per_body,
|
||||||
@@ -120,11 +110,7 @@ mod tests {
|
|||||||
assert!(!runtime.try_reserve_pending(1, 1, false, true));
|
assert!(!runtime.try_reserve_pending(1, 1, false, true));
|
||||||
|
|
||||||
runtime.release_pending(downlink_bytes, downlink_items, false);
|
runtime.release_pending(downlink_bytes, downlink_items, false);
|
||||||
runtime.release_pending(
|
runtime.release_pending(uplink_bytes, runtime.limits.max_frames_per_body, false);
|
||||||
uplink_bytes,
|
|
||||||
runtime.limits.max_frames_per_body,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
runtime.shutdown().await;
|
runtime.shutdown().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::state::{ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked};
|
||||||
use super::{ProfileKey, TokenHash, WebProcessRuntime};
|
use super::{ProfileKey, TokenHash, WebProcessRuntime};
|
||||||
use super::state::{
|
|
||||||
ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl WebProcessRuntime {
|
impl WebProcessRuntime {
|
||||||
/// Removes one closed session and retains a bounded host-bound replay marker.
|
/// 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.
|
/// Expires credentials and closes idle sessions without holding locks across callbacks.
|
||||||
pub(super) fn cleanup(&self) {
|
pub(super) fn cleanup(&self) {
|
||||||
let generation_id = self.active_runtime.load().id;
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let sessions = {
|
let sessions = {
|
||||||
let mut state = self.state.lock();
|
let mut state = self.state.lock();
|
||||||
remove_expired_locked(&mut state, now);
|
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<_>>()
|
state.sessions.values().cloned().collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
for session in sessions.into_iter().filter(|session| session.is_idle(now)) {
|
for session in sessions.into_iter().filter(|session| session.is_idle(now)) {
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ use crate::web::session::WebSession;
|
|||||||
|
|
||||||
/// One issued bootstrap and optional idempotent session-creation replay state.
|
/// One issued bootstrap and optional idempotent session-creation replay state.
|
||||||
pub(super) struct Bootstrap {
|
pub(super) struct Bootstrap {
|
||||||
/// Generation that issued the bootstrap.
|
|
||||||
pub(super) generation_id: u64,
|
|
||||||
/// Credential and replay-state expiry deadline.
|
/// Credential and replay-state expiry deadline.
|
||||||
pub(super) expires_at: Instant,
|
pub(super) expires_at: Instant,
|
||||||
/// Stable ordering point used for bounded eviction.
|
/// Stable ordering point used for bounded eviction.
|
||||||
pub(super) issued_at: Instant,
|
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,
|
pub(super) issuance_ip: IpAddr,
|
||||||
/// Immutable profile selected during capability validation.
|
/// Immutable profile selected during capability validation.
|
||||||
pub(super) profile: Arc<WebRuntimeProfile>,
|
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.
|
/// Applies one token-bucket admission decision at a caller-supplied monotonic time.
|
||||||
pub(super) fn allow_rate(
|
pub(super) fn allow_rate(state: &mut RateState, now: Instant, per_minute: u32, burst: u32) -> bool {
|
||||||
state: &mut RateState,
|
|
||||||
now: Instant,
|
|
||||||
per_minute: u32,
|
|
||||||
burst: u32,
|
|
||||||
) -> bool {
|
|
||||||
let burst = f64::from(burst);
|
let burst = f64::from(burst);
|
||||||
if let Some(last) = state.last {
|
if let Some(last) = state.last {
|
||||||
let elapsed = now.saturating_duration_since(last).as_secs_f64();
|
let elapsed = now.saturating_duration_since(last).as_secs_f64();
|
||||||
state.tokens =
|
state.tokens = (state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
|
||||||
(state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
|
|
||||||
} else {
|
} else {
|
||||||
state.tokens = burst;
|
state.tokens = burst;
|
||||||
}
|
}
|
||||||
@@ -218,9 +210,9 @@ where
|
|||||||
|
|
||||||
/// Computes the process-wide item reserve required for session control progress.
|
/// Computes the process-wide item reserve required for session control progress.
|
||||||
pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize {
|
pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize {
|
||||||
limits.max_sessions_global.saturating_mul(
|
limits
|
||||||
16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)),
|
.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.
|
/// Allocates a non-zero source port unique among live streams for one KDF route.
|
||||||
|
|||||||
+7
-5
@@ -314,7 +314,9 @@ impl WebSession {
|
|||||||
if !self.queue_window_locked(&mut state, stream_id, count as u32) {
|
if !self.queue_window_locked(&mut state, stream_id, count as u32) {
|
||||||
drop(state);
|
drop(state);
|
||||||
self.close();
|
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(()))
|
Poll::Ready(Ok(()))
|
||||||
}
|
}
|
||||||
@@ -368,14 +370,14 @@ impl WebSession {
|
|||||||
|
|
||||||
/// Returns the process queue-capacity notification source while the manager lives.
|
/// Returns the process queue-capacity notification source while the manager lives.
|
||||||
pub(super) fn budget_notify(&self) -> Option<Arc<Notify>> {
|
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) {
|
fn release_stream_reservation(&self, peer_port: u16) {
|
||||||
let removed = self.state.lock().active_peer_ports.remove(&peer_port);
|
let removed = self.state.lock().active_peer_ports.remove(&peer_port);
|
||||||
if removed
|
if removed && let Some(manager) = self.manager.upgrade() {
|
||||||
&& let Some(manager) = self.manager.upgrade()
|
|
||||||
{
|
|
||||||
manager.release_stream(
|
manager.release_stream(
|
||||||
self.profile_key,
|
self.profile_key,
|
||||||
self.client_ip,
|
self.client_ip,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::sync::Arc;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::proxy::shared_state::ConntrackClosePolicy;
|
||||||
use crate::web::frame::FrameType;
|
use crate::web::frame::FrameType;
|
||||||
use crate::web::stream::WebLogicalStream;
|
use crate::web::stream::WebLogicalStream;
|
||||||
use crate::proxy::shared_state::ConntrackClosePolicy;
|
|
||||||
|
|
||||||
use super::{WebSession, inbound_queue_cost};
|
use super::{WebSession, inbound_queue_cost};
|
||||||
|
|
||||||
@@ -70,9 +70,7 @@ impl WebSession {
|
|||||||
});
|
});
|
||||||
(queued, reserved)
|
(queued, reserved)
|
||||||
};
|
};
|
||||||
if reserved
|
if reserved && let Some(manager) = self.manager.upgrade() {
|
||||||
&& let Some(manager) = self.manager.upgrade()
|
|
||||||
{
|
|
||||||
manager.release_stream(
|
manager.release_stream(
|
||||||
self.profile_key,
|
self.profile_key,
|
||||||
self.client_ip,
|
self.client_ip,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ use std::time::{Duration, Instant};
|
|||||||
use bytes::{BufMut, Bytes, BytesMut};
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
|
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, WebSession,
|
||||||
WebSession,
|
|
||||||
};
|
};
|
||||||
use crate::config::WebCarrier;
|
use crate::config::WebCarrier;
|
||||||
use crate::web::frame::{self, FrameType};
|
use crate::web::frame::{self, FrameType};
|
||||||
@@ -117,9 +116,8 @@ impl WebSession {
|
|||||||
.limits
|
.limits
|
||||||
.pending_bytes_per_session
|
.pending_bytes_per_session
|
||||||
.saturating_sub(self.limits.control_bytes_per_session);
|
.saturating_sub(self.limits.control_bytes_per_session);
|
||||||
let item_reserve = 16usize.saturating_add(
|
let item_reserve =
|
||||||
self.limits.max_streams_per_session.saturating_mul(3),
|
16usize.saturating_add(self.limits.max_streams_per_session.saturating_mul(3));
|
||||||
);
|
|
||||||
let data_item_limit = self
|
let data_item_limit = self
|
||||||
.limits
|
.limits
|
||||||
.pending_items_per_session
|
.pending_items_per_session
|
||||||
@@ -146,10 +144,7 @@ impl WebSession {
|
|||||||
.pending_items
|
.pending_items
|
||||||
.saturating_sub(state.pending_control_items);
|
.saturating_sub(state.pending_control_items);
|
||||||
let (byte_limit, item_limit) = if class == PendingClass::Downlink {
|
let (byte_limit, item_limit) = if class == PendingClass::Downlink {
|
||||||
let uplink_bytes = self
|
let uplink_bytes = self.limits.max_body_bytes.saturating_add(
|
||||||
.limits
|
|
||||||
.max_body_bytes
|
|
||||||
.saturating_add(
|
|
||||||
self.limits
|
self.limits
|
||||||
.max_frames_per_body
|
.max_frames_per_body
|
||||||
.saturating_mul(QUEUE_ITEM_COST),
|
.saturating_mul(QUEUE_ITEM_COST),
|
||||||
@@ -172,12 +167,7 @@ impl WebSession {
|
|||||||
let Some(manager) = self.manager.upgrade() else {
|
let Some(manager) = self.manager.upgrade() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if !manager.try_reserve_pending(
|
if !manager.try_reserve_pending(bytes, items, control, class == PendingClass::Downlink) {
|
||||||
bytes,
|
|
||||||
items,
|
|
||||||
control,
|
|
||||||
class == PendingClass::Downlink,
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state.pending_bytes += bytes;
|
state.pending_bytes += bytes;
|
||||||
@@ -301,13 +291,7 @@ impl WebSession {
|
|||||||
control: bool,
|
control: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if self.carrier() == WebCarrier::HttpsLanes {
|
if self.carrier() == WebCarrier::HttpsLanes {
|
||||||
return self.queue_lane_frame_locked(
|
return self.queue_lane_frame_locked(state, frame_type, stream_id, payload, control);
|
||||||
state,
|
|
||||||
frame_type,
|
|
||||||
stream_id,
|
|
||||||
payload,
|
|
||||||
control,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
|
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
|
||||||
let class = if control {
|
let class = if control {
|
||||||
@@ -425,9 +409,7 @@ mod tests {
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
|
||||||
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
|
||||||
};
|
|
||||||
use crate::web::manager::WebProcessRuntime;
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
fn session() -> Arc<WebSession> {
|
fn session() -> Arc<WebSession> {
|
||||||
|
|||||||
+15
-18
@@ -7,8 +7,8 @@ use subtle::ConstantTimeEq;
|
|||||||
|
|
||||||
use super::uplink::{inbound_reservation, validate_batch};
|
use super::uplink::{inbound_reservation, validate_batch};
|
||||||
use super::{
|
use super::{
|
||||||
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame,
|
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
|
||||||
SessionState, WebSession, remember_closed,
|
WebSession, remember_closed,
|
||||||
};
|
};
|
||||||
use crate::config::{WebCarrier, WebLimitsConfig};
|
use crate::config::{WebCarrier, WebLimitsConfig};
|
||||||
use crate::web::frame::{self, Frame, FrameType};
|
use crate::web::frame::{self, Frame, FrameType};
|
||||||
@@ -32,9 +32,11 @@ impl WebSession {
|
|||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if frames.iter().copied().any(|value| {
|
if frames
|
||||||
value.stream_id != lane_id || frame::validate_client_shape(value).is_err()
|
.iter()
|
||||||
}) {
|
.copied()
|
||||||
|
.any(|value| value.stream_id != lane_id || frame::validate_client_shape(value).is_err())
|
||||||
|
{
|
||||||
self.close();
|
self.close();
|
||||||
return Err(ManagerError::Protocol);
|
return Err(ManagerError::Protocol);
|
||||||
}
|
}
|
||||||
@@ -48,13 +50,17 @@ impl WebSession {
|
|||||||
state.last_activity = Instant::now();
|
state.last_activity = Instant::now();
|
||||||
if !state.carrier_lanes.contains_key(&lane_id) {
|
if !state.carrier_lanes.contains_key(&lane_id) {
|
||||||
if lane_id != 0
|
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)
|
&& only_late_frames(&frames)
|
||||||
{
|
{
|
||||||
return Ok(sequence);
|
return Ok(sequence);
|
||||||
}
|
}
|
||||||
if lane_id == 0
|
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);
|
drop(state);
|
||||||
self.close();
|
self.close();
|
||||||
@@ -182,12 +188,7 @@ impl WebSession {
|
|||||||
};
|
};
|
||||||
if let Some(batch) = acknowledged {
|
if let Some(batch) = acknowledged {
|
||||||
self.release_locked(&mut state, batch.data_bytes, batch.data_items, false);
|
self.release_locked(&mut state, batch.data_bytes, batch.data_items, false);
|
||||||
self.release_locked(
|
self.release_locked(&mut state, batch.control_bytes, batch.control_items, true);
|
||||||
&mut state,
|
|
||||||
batch.control_bytes,
|
|
||||||
batch.control_items,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if let Some(stream) = state.streams.get_mut(&lane_id)
|
if let Some(stream) = state.streams.get_mut(&lane_id)
|
||||||
&& let Some(waker) = stream.write_waker.take()
|
&& 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) {
|
pub(super) fn remember_closed_locked(&self, state: &mut SessionState, stream_id: u32) {
|
||||||
let evicted = remember_closed(
|
let evicted = remember_closed(state, stream_id, self.limits.max_tombstones_per_session);
|
||||||
state,
|
|
||||||
stream_id,
|
|
||||||
self.limits.max_tombstones_per_session,
|
|
||||||
);
|
|
||||||
if self.carrier() != WebCarrier::HttpsLanes {
|
if self.carrier() != WebCarrier::HttpsLanes {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ use std::sync::Arc;
|
|||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{
|
use crate::config::{WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
|
||||||
WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
|
||||||
};
|
|
||||||
use crate::web::manager::WebProcessRuntime;
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
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());
|
state.carrier_lanes.insert(7, CarrierLane::new());
|
||||||
let encoded = frame::encode(FrameType::Close, 7, &[]);
|
let encoded = frame::encode(FrameType::Close, 7, &[]);
|
||||||
let cost = encoded.len() + QUEUE_ITEM_COST;
|
let cost = encoded.len() + QUEUE_ITEM_COST;
|
||||||
state.carrier_lanes.get_mut(&7).unwrap().pending_frames.push_back(
|
state
|
||||||
QueuedFrame {
|
.carrier_lanes
|
||||||
|
.get_mut(&7)
|
||||||
|
.unwrap()
|
||||||
|
.pending_frames
|
||||||
|
.push_back(QueuedFrame {
|
||||||
encoded: BytesMut::from(encoded.as_ref()),
|
encoded: BytesMut::from(encoded.as_ref()),
|
||||||
frame_type: FrameType::Close,
|
frame_type: FrameType::Close,
|
||||||
stream_id: 7,
|
stream_id: 7,
|
||||||
control: true,
|
control: true,
|
||||||
cost,
|
cost,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
state.pending_bytes = cost;
|
state.pending_bytes = cost;
|
||||||
state.pending_items = 1;
|
state.pending_items = 1;
|
||||||
state.pending_control_bytes = cost;
|
state.pending_control_bytes = cost;
|
||||||
|
|||||||
@@ -138,12 +138,8 @@ impl WebSession {
|
|||||||
FrameType::Open => {
|
FrameType::Open => {
|
||||||
let Some(peer_port) = self.reserve_stream_locked(state) else {
|
let Some(peer_port) = self.reserve_stream_locked(state) else {
|
||||||
self.remember_closed_locked(state, value.stream_id);
|
self.remember_closed_locked(state, value.stream_id);
|
||||||
if !self.queue_control_locked(
|
if !self.queue_control_locked(state, FrameType::Close, value.stream_id, &[])
|
||||||
state,
|
{
|
||||||
FrameType::Close,
|
|
||||||
value.stream_id,
|
|
||||||
&[],
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -169,8 +165,8 @@ impl WebSession {
|
|||||||
bytes: Bytes::copy_from_slice(value.payload),
|
bytes: Bytes::copy_from_slice(value.payload),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
*unused_bytes = unused_bytes
|
*unused_bytes =
|
||||||
.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
|
unused_bytes.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
|
||||||
*unused_items = unused_items.saturating_sub(1);
|
*unused_items = unused_items.saturating_sub(1);
|
||||||
if let Some(waker) = stream.read_waker.take() {
|
if let Some(waker) = stream.read_waker.take() {
|
||||||
waker.wake();
|
waker.wake();
|
||||||
@@ -256,8 +252,8 @@ pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let was_closed = state.closed_streams.contains(&value.stream_id)
|
let was_closed =
|
||||||
|| closed.contains(&value.stream_id);
|
state.closed_streams.contains(&value.stream_id) || closed.contains(&value.stream_id);
|
||||||
match value.frame_type {
|
match value.frame_type {
|
||||||
FrameType::Open => {
|
FrameType::Open => {
|
||||||
if live.contains_key(&value.stream_id) || was_closed {
|
if live.contains_key(&value.stream_id) || was_closed {
|
||||||
@@ -308,10 +304,7 @@ pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn inbound_reservation(
|
pub(super) fn inbound_reservation(state: &SessionState, frames: &[Frame<'_>]) -> (usize, usize) {
|
||||||
state: &SessionState,
|
|
||||||
frames: &[Frame<'_>],
|
|
||||||
) -> (usize, usize) {
|
|
||||||
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
|
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
|
||||||
let mut bytes = 0usize;
|
let mut bytes = 0usize;
|
||||||
let mut items = 0usize;
|
let mut items = 0usize;
|
||||||
@@ -338,9 +331,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig};
|
||||||
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
|
||||||
};
|
|
||||||
use crate::web::manager::WebProcessRuntime;
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
fn session() -> Arc<WebSession> {
|
fn session() -> Arc<WebSession> {
|
||||||
@@ -383,10 +374,7 @@ mod tests {
|
|||||||
let session = session();
|
let session = session();
|
||||||
let body = frame::encode(FrameType::Pong, 0, &[]);
|
let body = frame::encode(FrameType::Pong, 0, &[]);
|
||||||
session.up_active.store(true, Ordering::Release);
|
session.up_active.store(true, Ordering::Release);
|
||||||
assert_eq!(
|
assert_eq!(session.process_up(1, &body), Err(ManagerError::Concurrent));
|
||||||
session.process_up(1, &body),
|
|
||||||
Err(ManagerError::Concurrent)
|
|
||||||
);
|
|
||||||
assert_eq!(session.state.lock().last_up_sequence, 0);
|
assert_eq!(session.state.lock().last_up_sequence, 0);
|
||||||
session.up_active.store(false, Ordering::Release);
|
session.up_active.store(false, Ordering::Release);
|
||||||
assert_eq!(session.process_up(1, &body), Ok(1));
|
assert_eq!(session.process_up(1, &body), Ok(1));
|
||||||
|
|||||||
Reference in New Issue
Block a user