Merge pull request #898 from telemt/flow-web

MTProxy WEB: https + https-lanes
This commit is contained in:
Alexey
2026-08-23 09:12:30 +03:00
committed by GitHub
67 changed files with 9740 additions and 2648 deletions
Generated
+1 -1
View File
@@ -2900,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "telemt"
version = "3.5.0"
version = "3.5.1"
dependencies = [
"aes",
"anyhow",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.5.0"
version = "3.5.1"
edition = "2024"
[features]
@@ -72,7 +72,7 @@ anyhow = "1.0.102"
reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
notify = "8.2.0"
ipnetwork = { version = "0.21.1", features = ["serde"] }
hyper = { version = "1.10.1", features = ["server", "http1"] }
hyper = { version = "1.10.1", features = ["client", "server", "http1"] }
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
http-body-util = "0.1.3"
httpdate = "1.0.3"
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
### 3.0.0 Anschluss
- **Middle Proxy now is stable**, confirmed on canary-deploy over ~20 users
- Ad-tag now is working
- DC=203/CDN now is working over ME
- `getProxyConfig` and `ProxySecret` are automated
- Version order is now in format `3.0.0` - without Windows-style "microfixes"
### 3.0.1 Kabelsammler
- Handshake timeouts fixed
- Connectivity logging refactored
- Docker: tmpfs for ProxyConfig and ProxySecret
- Public Host and Port in config
- ME Relays Head-of-Line Blocking fixed
- ME Ping
### 3.0.2 Microtrencher
- New [network] section
- ME Fixes
- Small bugs coverage
### 3.0.3 Ausrutscher
- ME as stateful, no conn-id migration
- No `flush()` on datapath after RpcWriter
- Hightech parser for IPv6 without regexp
- `nat_probe = true` by default
- Timeout for `recv()` in STUN-client
- ConnRegistry review
- Dualstack emergency reconnect
### 3.0.4 Schneeflecken
- Only WARN and Links in Normal log
- Consistent IP-family detection
- Includes for config
- `nonce_frame_hex` in log only with `DEBUG`
+25 -2
View File
@@ -265,7 +265,7 @@ A sparse JSON object containing only the top-level config sections to modify. Ea
**Rejected keys:**
- `access``400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
- `network`, or any unknown top-level key → `400 section_not_editable`.
- `network`, `web`, or any unknown top-level key → `400 section_not_editable`.
- `server` with any key other than `listeners` (e.g. `port`, `api`, `admin_api`) → `400 field_not_editable`.
- An object with no editable keys → `400 bad_request` (empty patch).
@@ -1418,7 +1418,7 @@ Applies a sparse patch to the editable config sections. The merged config is ful
| Key | HTTP | `error.code` |
| --- | --- | --- |
| `access` | `400` | `access_not_editable` |
| `network`, or any unknown top-level key | `400` | `section_not_editable` |
| `network`, `web`, or any unknown top-level key | `400` | `section_not_editable` |
| `server` with keys other than `listeners` | `400` | `field_not_editable` |
| Object with no editable key | `400` | `bad_request` |
@@ -1522,6 +1522,29 @@ Reload preparation requires every configured TLS-front domain to have a non-defa
The revision is verified again after preparation. With `failure_policy=rollback`, a changed revision or revision read failure rolls the candidate back; with `failure_policy=keep_new`, the condition is reported in `warnings` and activation continues.
## WEB Proxy Management
The API provides partial operational control for WEB mode; it does not expose a dedicated `/v1/web` resource.
| Operation | Current contract |
| --- | --- |
| Read or patch `[web]`, vhosts, profiles, decoys, timeouts, or limits | Not exposed. `GET /v1/config` omits `[web]`; a `web` key in `PATCH /v1/config` returns `400 section_not_editable`. |
| Persist `server.listeners` | Supported through `PATCH /v1/config`. Arrays replace wholesale. A changed WEB listener is process-owned and remains deferred until process restart. |
| Apply an externally edited WEB config | Update the owning TOML source, call `POST /v1/system/reload`, then poll `GET /v1/system/reload/{id}`. |
| Inspect restart requirements | Read `deferred_process_fields` from reload status. `server.listeners` and `web.limits` require process restart. |
| Manage access users | Use `/v1/users`. Creating a user does not add it to `web.vhosts.profiles`; profile membership remains file-managed. |
| Disable one user | `POST /v1/users/{username}/disable` updates admission immediately and cancels the user's active sessions. |
| Rotate a profiled user's secret | Use `/v1/users/{username}/rotate-secret`; the config watcher rebuilds WEB capabilities from the new access snapshot. The API returns the secret, not a `tg://webproxy` link. |
| Read WEB-specific runtime statistics | No WEB-specific endpoint exists in the current API surface. |
`web.enabled`, `web.carrier`, `web.timeouts`, vhosts, profiles, and decoy snapshots are runtime-generation fields. A changed carrier applies only to newly issued bridge sessions; existing sessions retain their creation-time carrier. WEB listener inventory and trust policy, plus all `[web.limits]`, are process-owned. A successful reload can therefore activate the runtime-owned subset while reporting the process-owned subset as deferred.
Before deleting a user referenced by a WEB profile, remove and apply the profile first. User mutations validate the complete resulting configuration, so a dangling WEB profile is rejected rather than persisted.
The API whitelist is evaluated against the direct TCP peer and does not use the WEB listener's `X-Forwarded-For` policy. Keep the API on a separate loopback or private bind, use a narrow whitelist and a non-empty exact `auth_header`, and do not expose it through the public WEB vhost.
Deployment, TLS-terminator examples, links, and WEB-specific verification are documented in the [WEB proxy guide](../../WEB/WEB_PROXY.en.md).
## Mutation Semantics
| Endpoint | Notes |
+145
View File
@@ -24,6 +24,12 @@ Dieses Dokument listet alle Konfigurationsschlüssel auf, die `config.toml` akze
- [server.conntrack_control](#serverconntrack_control)
- [server.api](#serverapi)
- [server.listeners](#serverlisteners)
- [web](#web)
- [web.limits](#weblimits)
- [web.timeouts](#webtimeouts)
- [web.vhosts](#webvhosts)
- [web.vhosts.decoy](#webvhostsdecoy)
- [web.vhosts.profiles](#webvhostsprofiles)
- [timeouts](#timeouts)
- [censorship](#censorship)
- [censorship.tls_fetch](#censorshiptls_fetch)
@@ -2324,6 +2330,9 @@ Hinweis: Dieser Abschnitt akzeptiert auch den Legacy-Alias `[server.admin_api]`
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
| [`reuse_allow`](#reuse_allow) | `bool` | `false` | `` |
| [`transport`](#transport-serverlisteners) | `"mtproxy"` oder `"web"` | `"mtproxy"` | `` |
| [`web_client_ip_source`](#web_client_ip_source-serverlisteners) | `"x_forwarded_for"` | `"x_forwarded_for"` | `` |
| [`web_trusted_proxy_cidrs`](#web_trusted_proxy_cidrs-serverlisteners) | `IpNetwork[]` | `[]` | `` |
## ip
- **Einschränkungen / Validierung**: Erforderliches Feld. Muss ein `IpAddr` sein.
@@ -2517,6 +2526,142 @@ Hinweis: Dieser Abschnitt akzeptiert auch den Legacy-Alias `[server.admin_api]`
reuse_allow = false
```
## transport (server.listeners)
- **Einschränkungen / Validierung**: `"mtproxy"` oder `"web"`.
- **Beschreibung**: Wählt das vom Listener akzeptierte Protokoll. Ein WEB-Listener empfängt unverschlüsseltes HTTP/1.1 von einem vertrauenswürdigen TLS-Terminator und erfordert einen Prozessneustart. Er muss `proxy_protocol = false` und `reuse_allow = false` verwenden; `client_mss`, `synlimit`, `announce` und `announce_ip` sind nicht zulässig.
- **Beispiel**:
```toml
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
```
## web_client_ip_source (server.listeners)
- **Einschränkungen / Validierung**: Die erste WEB-Implementierung unterstützt ausschließlich `"x_forwarded_for"`.
- **Beschreibung**: Wählt die L7-Quelle der ursprünglichen Client-IP. Telemt akzeptiert genau eine kanonische `X-Forwarded-For`-Adresse und nur dann, wenn der direkte TCP-Peer zu `web_trusted_proxy_cidrs` gehört.
## 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.
- **Beschreibung**: Vertrauensgrenze für den unmittelbar vorgeschalteten NGINX- oder HAProxy-Peer. Tragen Sie nur Adressen ein, die diesen Listener direkt erreichen können, und veröffentlichen Sie den unverschlüsselten Listener niemals in einem nicht vertrauenswürdigen Netz.
# [web]
Der WEB-Modus transportiert MTProxy-Datenverkehr von Telegram Desktop über HTTPS, dessen TLS-Verbindung von einem externen NGINX oder HAProxy terminiert wird. Telemt empfängt unverschlüsseltes HTTP/1.1 auf einem privaten Listener mit `transport = "web"`. Lesen Sie vor der Aktivierung die [vollständige WEB-Bereitstellungsanleitung](../WEB/WEB_PROXY.de.md).
| Schlüssel | Typ | Default | Hot-Reload |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | `` |
| `carrier` | `"https"` oder `"https-lanes"` | `"https"` | `` |
| `limits` | Tabelle | begrenzte Defaults | `` |
| `timeouts` | Tabelle | begrenzte Defaults | `` |
| `vhosts` | Tabellen-Array | `[]` | `` |
`enabled = true` erfordert mindestens einen durch die Netzwerkrichtlinie zugelassenen WEB-Listener, einen vhost und mindestens ein Profil in jedem vhost. `carrier = "https"` behält den serialisierten HTTPS-Transport bei. Mit `carrier = "https-lanes"` erhalten Stream null und jeder logische Stream eigene Uplink-Sequenzen, Downlink-Cursor, Wiederholungen und Long Polls; dieser Carrier erfordert `max_http_handlers >= 2` und öffentliches HTTP/2 am TLS-Terminator, um anwendungsseitiges Head-of-Line-Blocking zwischen Streams zu entfernen. Ein Reload wendet `carrier` nur auf neu ausgegebene Bridge-Sitzungen an. Das Deaktivieren von WEB beendet nach dem Reload die Ausgabe neuer Bridge- und Session-Zugangsdaten; zum Widerrufen aktiver Sitzungen eines einzelnen Benutzers verwenden Sie die Users-API.
# [web.limits]
Diese prozessweiten Obergrenzen begrenzen alle WEB-Register, Warteschlangen, Request-Bodys, statischen Snapshots und Admission-Pfade. Alle Werte werden gemeinsam validiert: Eigentümerbezogene Grenzen dürfen die globalen Grenzen nicht überschreiten, Queue-Reserven müssen den Fortschritt von Control Frames gewährleisten, Body-Reservierungen müssen in ihr globales Budget passen und alle deklarierten Byte-Grenzen müssen in `memory_envelope_bytes` passen. Jede Änderung in dieser Tabelle erfordert einen Prozessneustart.
| Schlüssel | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `max_header_bytes` | `usize` | `16384` | Maximale Bytes in einem HTTP-Request-Head. |
| `max_body_bytes` | `usize` | `2097152` | Maximale Größe eines gesammelten Carrier-Request-Bodys. |
| `max_frame_payload_bytes` | `usize` | `1048576` | Maximale Nutzlast eines WEB-Frames. |
| `carrier_batch_bytes` | `usize` | `2097152` | Maximale Größe eines kodierten Downlink-Batches. |
| `max_frames_per_body` | `usize` | `4096` | Maximale Zahl geparster oder ausgegebener Frames pro Carrier-Body. |
| `max_http_connections` | `usize` | `1024` | Prozessweit akzeptierte WEB-HTTP-Verbindungen. |
| `max_http_handlers` | `usize` | `512` | Prozessweit gleichzeitig ausgeführte HTTP-Handler; HTTPS-Lanes dürfen höchstens die Hälfte mit Long Polls belegen, der Rest bleibt für Session-, Uplink- und Steuerarbeit verfügbar. |
| `max_body_readers` | `usize` | `32` | Prozessweit gleichzeitig gesammelte Request-Bodys. |
| `max_body_bytes_global` | `usize` | `67108864` | Globales Byte-Budget für gesammelte Bodys. |
| `max_sessions_global` | `usize` | `128` | Prozessweit aktive WEB-Sitzungen. |
| `max_sessions_per_ip` | `usize` | `16` | Aktive Sitzungen pro weitergeleiteter Client-IP. |
| `max_streams_per_session` | `usize` | `128` | Standardgrenze aktiver logischer Streams pro Sitzung. |
| `max_streams_global` | `usize` | `4096` | Prozessweit aktive logische Streams. |
| `max_stream_handshakes` | `usize` | `256` | Gleichzeitig ausgeführte innere MTProxy-Handshakes. |
| `max_tombstones_per_session` | `usize` | `4096` | Pro Sitzung gespeicherte IDs geschlossener Streams. |
| `pending_bytes_per_session` | `usize` | `33554432` | Eingereihte Daten- und Steuerbytes pro Sitzung. |
| `pending_bytes_global` | `usize` | `536870912` | Prozessweit eingereihte Daten- und Steuerbytes. |
| `pending_items_per_session` | `usize` | `16384` | Eingereihte Daten- und Steuerelemente pro Sitzung. |
| `pending_items_global` | `usize` | `262144` | Prozessweit eingereihte Daten- und Steuerelemente. |
| `control_bytes_per_session` | `usize` | `262144` | Nur für Control Frames verfügbares Byte-Budget pro Sitzung. |
| `control_bytes_global` | `usize` | `16777216` | Prozessweites, nur für Control Frames verfügbares Byte-Budget. |
| `max_bootstraps_global` | `usize` | `512` | Prozessweit aktive Bootstrap-Zugangsdaten. |
| `max_bootstraps_per_ip` | `usize` | `64` | Aktive Bootstrap-Zugangsdaten pro Client-IP. |
| `max_vhosts` | `usize` | `8` | Konfigurierte virtuelle WEB-Hosts. |
| `max_profiles` | `usize` | `32` | WEB-Profile über alle vhosts. |
| `max_static_files` | `usize` | `4096` | Einträge statischer Snapshots über alle vhosts. |
| `max_static_file_bytes` | `usize` | `8388608` | Maximale Größe einer statischen Datei. |
| `max_static_bytes` | `usize` | `67108864` | Bytes statischer Snapshots über alle vhosts. |
| `memory_envelope_bytes` | `usize` | `805306368` | Deklarierter Rahmen für HTTP-Heads, Bodys, Queues und statische Snapshots; maximal 4 GiB. |
| `new_bootstraps_per_minute` | `u32` | `1200` | Nachhaltige prozessweite Ausgaberate für Bootstraps. |
| `new_bootstraps_burst` | `u32` | `256` | Prozessweiter Burst für die Bootstrap-Ausgabe. |
| `new_sessions_per_minute` | `u32` | `600` | Nachhaltige prozessweite Erstellungsrate für Sitzungen. |
| `new_sessions_burst` | `u32` | `128` | Prozessweiter Burst für die Sitzungserstellung. |
| `new_streams_per_minute` | `u32` | `6000` | Nachhaltige Erstellungsrate für logische Streams. |
| `new_streams_burst` | `u32` | `512` | Prozessweiter Burst für die Stream-Erstellung. |
# [web.timeouts]
Alle Timeouts werden in Sekunden angegeben und müssen im Bereich `1..=3600` liegen. Die längste Request-Deadline muss kleiner als `http_idle_secs` sein.
| Schlüssel | Typ | Default | Hot-Reload | Beschreibung |
| --- | --- | --- | --- | --- |
| `header_secs` | `u64` | `10` | `` | Empfang eines vollständigen HTTP-Request-Heads. |
| `body_secs` | `u64` | `30` | `` | Sammeln eines authentifizierten Carrier-Bodys. |
| `stream_handshake_secs` | `u64` | `10` | `` | Abschluss eines inneren MTProxy-Handshakes. |
| `long_poll_secs` | `u64` | `25` | `` | Maximale Dauer eines leeren Downlink-Long-Polls. |
| `bootstrap_lifetime_secs` | `u64` | `120` | `` | Lebensdauer ungenutzter Bootstraps und geschlossener Token-Replay-Marker. |
| `reconnect_grace_secs` | `u64` | `120` | `` | Maximale Carrier-Inaktivität bis zum Schließen der Sitzung. |
| `http_idle_secs` | `u64` | `75` | `` | Idle-Lebensdauer einer WEB-HTTP-Keep-Alive-Verbindung. |
| `shutdown_secs` | `u64` | `15` | `` | Deadline für das kontrollierte Beenden von WEB. |
| `decoy_header_secs` | `u64` | `30` | `` | Deadline für Verbindung und Response-Head eines HTTP-Decoys. |
# [[web.vhosts]]
| Schlüssel | Typ | Erforderlich | Hot-Reload | Beschreibung |
| --- | --- | --- | --- | --- |
| `host` | `String` | ja | `` | Eindeutiger, kanonischer ACE-FQDN in Kleinbuchstaben ohne Port, Pfad, Zugangsdaten oder abschließenden Punkt. |
| `public_addr` | `SocketAddr` | ja | `` | Konkrete öffentliche IP auf Port `443`; wird im Ziel-Tupel des inneren Relays verwendet. |
| `decoy` | Tabelle | ja | `` | Gewöhnlicher Site-Fallback für nicht authentifizierten oder ungültigen Datenverkehr. |
| `profiles` | Tabellen-Array | bei aktiviertem WEB | `` | Explizite Benutzer und Client-Secret-Modi für diesen Hostnamen. |
Die weitergeleitete Client-Adresse und `public_addr` müssen dieselbe IP-Familie verwenden. Der Hostname wird bei der Validierung normalisiert und muss von Telegram Desktop akzeptiert werden.
# [web.vhosts.decoy]
Genau ein Decoy-Modus ist erforderlich:
| Modus | Erforderliche Schlüssel | Validierung |
| --- | --- | --- |
| `http_upstream` | `upstream` | Ein `http://`-Origin mit Loopback-, Link-Local- oder privater IP-Adresse als Literal; keine Zugangsdaten, kein Pfad, Query oder Fragment. |
| `static_directory` | `directory`; optional `index = "index.html"` | Absolutes reales Verzeichnis und ein sicherer Index-Dateiname. Symlinks und Pfade außerhalb des Verzeichnisses werden abgelehnt; der unveränderliche Snapshot wird innerhalb von `[web.limits]` geladen. |
# [[web.vhosts.profiles]]
| Schlüssel | Typ | Erforderlich | Default | Beschreibung |
| --- | --- | --- | --- | --- |
| `user` | `String` | ja | — | Vorhandener Schlüssel aus `[access.users]`. |
| `secret_mode` | `"plain"` oder `"dd"` | ja | — | Exakte Secret-Darstellung für Telegram Desktop. `ee` wird nicht unterstützt. |
| `max_sessions` | `usize` | nein | `web.limits.max_sessions_global` | Aktive Sitzungen für dieses Profil. |
| `max_streams` | `usize` | nein | `web.limits.max_streams_global` | Aktive logische Streams für dieses Profil. |
| `max_streams_per_session` | `usize` | nein | `web.limits.max_streams_per_session` | Aktive logische Streams in einer Profilsitzung. |
Profilgrenzen müssen ungleich null sein und dürfen die zugehörigen globalen Grenzen nicht überschreiten. Doppelte `(user, secret_mode)`-Profile in einem vhost werden abgelehnt.
## WEB-Lebenszyklus und API-Verwaltung
- Config-Watcher und Generations-Reload wenden `web.enabled`, `web.carrier`, `web.timeouts`, vhosts, Profile und Decoy-Snapshots ohne Prozessneustart an. Bestehende Sitzungen behalten Carrier, Grenzen und Deadlines ihres Erstellungszeitpunkts; neu ausgegebene Bridge-Sitzungen verwenden die aktive Generation.
- Bestand und Vertrauensrichtlinie der WEB-Listener unter `server.listeners` sowie alle Werte in `web.limits` sind prozesseigen und erfordern einen Neustart.
- Es gibt keinen eigenen Endpunkt `/v1/web`. `GET /v1/config` lässt `[web]` aus und `PATCH /v1/config` lehnt einen Schlüssel `web` mit `400 section_not_editable` ab.
- Zum entfernten Anwenden einer WEB-Richtlinie ändern Sie die zuständige TOML-Datei und rufen `POST /v1/system/reload` auf. Prüfen Sie anschließend `GET /v1/system/reload/{id}` und dessen `deferred_process_fields`. Starten Sie Telemt neu, wenn das Feld `server.listeners` oder `web.limits` enthält.
- Vorhandene Access-Benutzer können über `/v1/users` erstellt, geändert, rotiert, aktiviert, deaktiviert und gelöscht werden. Das Erstellen eines Benutzers fügt kein WEB-Profil hinzu. Das Deaktivieren aktualisiert die Admission sofort und beendet die aktiven Sitzungen dieses Benutzers.
- `PATCH /v1/config` kann `server.listeners` einschließlich der WEB-Listener-Felder speichern; ein geänderter WEB-Listener wird jedoch erst nach einem Prozessneustart aktiv.
# [timeouts]
+145
View File
@@ -24,6 +24,12 @@ This document lists all configuration keys accepted by `config.toml`.
- [server.conntrack_control](#serverconntrack_control)
- [server.api](#serverapi)
- [server.listeners](#serverlisteners)
- [web](#web)
- [web.limits](#weblimits)
- [web.timeouts](#webtimeouts)
- [web.vhosts](#webvhosts)
- [web.vhosts.decoy](#webvhostsdecoy)
- [web.vhosts.profiles](#webvhostsprofiles)
- [timeouts](#timeouts)
- [censorship](#censorship)
- [censorship.tls_fetch](#censorshiptls_fetch)
@@ -2324,6 +2330,9 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
| [`reuse_allow`](#reuse_allow) | `bool` | `false` | `✘` |
| [`transport`](#transport-serverlisteners) | `"mtproxy"` or `"web"` | `"mtproxy"` | `✘` |
| [`web_client_ip_source`](#web_client_ip_source-serverlisteners) | `"x_forwarded_for"` | `"x_forwarded_for"` | `✘` |
| [`web_trusted_proxy_cidrs`](#web_trusted_proxy_cidrs-serverlisteners) | `IpNetwork[]` | `[]` | `✘` |
## ip
- **Constraints / validation**: Required field. Must be an `IpAddr`.
@@ -2517,6 +2526,142 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
reuse_allow = false
```
## transport (server.listeners)
- **Constraints / validation**: `"mtproxy"` or `"web"`.
- **Description**: Selects the protocol accepted by this listener. A WEB listener receives plain HTTP/1.1 from a trusted TLS terminator and is restart-required. It must set `proxy_protocol = false`, `reuse_allow = false`, and cannot use `client_mss`, `synlimit`, `announce`, or `announce_ip`.
- **Example**:
```toml
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
```
## web_client_ip_source (server.listeners)
- **Constraints / validation**: Only `"x_forwarded_for"` is supported by the initial WEB implementation.
- **Description**: Chooses the L7 source of the original client IP. Telemt accepts exactly one canonical `X-Forwarded-For` address and only when the direct TCP peer belongs to `web_trusted_proxy_cidrs`.
## 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.
- **Description**: Trust boundary for the immediate NGINX or HAProxy peer. List only addresses that can connect directly to this listener; never expose the plain listener to an untrusted network.
# [web]
WEB mode carries Telegram Desktop MTProxy traffic through HTTPS terminated by an external NGINX or HAProxy. Telemt receives plain HTTP/1.1 on a private `transport = "web"` listener. See the [complete WEB deployment guide](../WEB/WEB_PROXY.en.md) before enabling this mode.
| Key | Type | Default | Hot-Reload |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | `✔` |
| `carrier` | `"https"` or `"https-lanes"` | `"https"` | `✔` |
| `limits` | table | bounded defaults | `✘` |
| `timeouts` | table | bounded defaults | `✔` |
| `vhosts` | array of tables | `[]` | `✔` |
`enabled = true` requires at least one network-eligible WEB listener, at least one vhost, and at least one profile in every vhost. `carrier = "https"` preserves the serialized HTTPS transport. `carrier = "https-lanes"` gives stream zero and every logical stream independent uplink sequencing, downlink cursors, retries, and long polls; it requires `max_http_handlers >= 2` and public HTTP/2 on the TLS terminator to remove application-level inter-stream head-of-line blocking. A reload applies `carrier` only to newly issued bridge sessions. Disabling WEB stops issuance of new bridge and session credentials after reload; use the users API to revoke one user's active sessions.
# [web.limits]
These process-wide ceilings make every WEB registry, queue, request body, static snapshot, and admission path bounded. All values are validated together. Per-owner limits cannot exceed global limits, queue reserves must preserve control-frame progress, body reservations must fit their global budget, and all declared byte ceilings must fit `memory_envelope_bytes`. Changing any value in this table requires a process restart.
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `max_header_bytes` | `usize` | `16384` | Maximum bytes in one HTTP request head. |
| `max_body_bytes` | `usize` | `2097152` | Maximum collected carrier request body. |
| `max_frame_payload_bytes` | `usize` | `1048576` | Maximum payload in one WEB frame. |
| `carrier_batch_bytes` | `usize` | `2097152` | Maximum encoded downlink batch. |
| `max_frames_per_body` | `usize` | `4096` | Maximum frames parsed or emitted per carrier body. |
| `max_http_connections` | `usize` | `1024` | Accepted WEB HTTP connections process-wide. |
| `max_http_handlers` | `usize` | `512` | Concurrent HTTP handlers process-wide; HTTPS lanes may park at most half, preserving the remainder for session, uplink, and control work. |
| `max_body_readers` | `usize` | `32` | Concurrent collected request bodies process-wide. |
| `max_body_bytes_global` | `usize` | `67108864` | Global byte reservation for collected bodies. |
| `max_sessions_global` | `usize` | `128` | Live WEB sessions process-wide. |
| `max_sessions_per_ip` | `usize` | `16` | Live sessions for one forwarded client IP. |
| `max_streams_per_session` | `usize` | `128` | Default live logical streams per session. |
| `max_streams_global` | `usize` | `4096` | Live logical streams process-wide. |
| `max_stream_handshakes` | `usize` | `256` | Concurrent inner MTProxy handshakes. |
| `max_tombstones_per_session` | `usize` | `4096` | Closed stream IDs retained per session. |
| `pending_bytes_per_session` | `usize` | `33554432` | Queued data and control bytes per session. |
| `pending_bytes_global` | `usize` | `536870912` | Queued data and control bytes process-wide. |
| `pending_items_per_session` | `usize` | `16384` | Queued data and control items per session. |
| `pending_items_global` | `usize` | `262144` | Queued data and control items process-wide. |
| `control_bytes_per_session` | `usize` | `262144` | Per-session byte reserve available only to control frames. |
| `control_bytes_global` | `usize` | `16777216` | Process-wide byte reserve available only to control frames. |
| `max_bootstraps_global` | `usize` | `512` | Live bootstrap credentials process-wide. |
| `max_bootstraps_per_ip` | `usize` | `64` | Live bootstrap credentials per client IP. |
| `max_vhosts` | `usize` | `8` | Configured WEB virtual hosts. |
| `max_profiles` | `usize` | `32` | WEB profiles across all vhosts. |
| `max_static_files` | `usize` | `4096` | Static snapshot entries across all vhosts. |
| `max_static_file_bytes` | `usize` | `8388608` | Maximum bytes in one static file. |
| `max_static_bytes` | `usize` | `67108864` | Static snapshot bytes across all vhosts. |
| `memory_envelope_bytes` | `usize` | `805306368` | Declared envelope for HTTP heads, bodies, queues, and static snapshots; maximum 4 GiB. |
| `new_bootstraps_per_minute` | `u32` | `1200` | Sustained process-wide bootstrap issuance rate. |
| `new_bootstraps_burst` | `u32` | `256` | Process-wide bootstrap issuance burst. |
| `new_sessions_per_minute` | `u32` | `600` | Sustained process-wide session creation rate. |
| `new_sessions_burst` | `u32` | `128` | Process-wide session creation burst. |
| `new_streams_per_minute` | `u32` | `6000` | Sustained logical-stream creation rate. |
| `new_streams_burst` | `u32` | `512` | Process-wide logical-stream creation burst. |
# [web.timeouts]
Every timeout is measured in seconds and must be within `1..=3600`. The longest request deadline must be lower than `http_idle_secs`.
| Key | Type | Default | Hot-Reload | Description |
| --- | --- | --- | --- | --- |
| `header_secs` | `u64` | `10` | `✔` | Receive one complete HTTP request head. |
| `body_secs` | `u64` | `30` | `✔` | Collect one authenticated carrier body. |
| `stream_handshake_secs` | `u64` | `10` | `✔` | Complete one inner MTProxy handshake. |
| `long_poll_secs` | `u64` | `25` | `✔` | Maximum empty downlink long poll. |
| `bootstrap_lifetime_secs` | `u64` | `120` | `✔` | Unused bootstrap and closed-token replay lifetime. |
| `reconnect_grace_secs` | `u64` | `120` | `✔` | Maximum carrier inactivity before session closure. |
| `http_idle_secs` | `u64` | `75` | `✔` | WEB HTTP keep-alive idle lifetime. |
| `shutdown_secs` | `u64` | `15` | `✔` | Graceful WEB shutdown deadline. |
| `decoy_header_secs` | `u64` | `30` | `✔` | Connect and response-head deadline for an HTTP decoy. |
# [[web.vhosts]]
| Key | Type | Required | Hot-Reload | Description |
| --- | --- | --- | --- | --- |
| `host` | `String` | yes | `✔` | Unique, canonical lowercase ACE FQDN without port, path, credentials, or trailing dot. |
| `public_addr` | `SocketAddr` | yes | `✔` | Concrete public IP on port `443`; used in the inner relay destination tuple. |
| `decoy` | table | yes | `✔` | Ordinary-site fallback for unauthenticated or invalid traffic. |
| `profiles` | array of tables | when enabled | `✔` | Explicit users and client secret modes exposed by this hostname. |
The forwarded client address and `public_addr` must use the same IP family. The hostname must be accepted by Telegram Desktop and is normalized during validation.
# [web.vhosts.decoy]
Exactly one decoy mode is required:
| Mode | Required keys | Validation |
| --- | --- | --- |
| `http_upstream` | `upstream` | An `http://` origin using a loopback, link-local, or private IP literal; no credentials, path, query, or fragment. |
| `static_directory` | `directory`; optional `index = "index.html"` | Absolute real directory and one safe index file name. Symlinks and escaping paths are rejected; the immutable snapshot is loaded under `[web.limits]`. |
# [[web.vhosts.profiles]]
| Key | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `user` | `String` | yes | — | Existing key from `[access.users]`. |
| `secret_mode` | `"plain"` or `"dd"` | yes | — | Exact Telegram Desktop secret representation. `ee` is not supported. |
| `max_sessions` | `usize` | no | `web.limits.max_sessions_global` | Live sessions for this profile. |
| `max_streams` | `usize` | no | `web.limits.max_streams_global` | Live logical streams for this profile. |
| `max_streams_per_session` | `usize` | no | `web.limits.max_streams_per_session` | Live logical streams in one profile session. |
Profile limits must be non-zero and no greater than their corresponding global limits. Duplicate `(user, secret_mode)` profiles in one vhost are rejected.
## WEB lifecycle and API management
- The config watcher and generation reload apply `web.enabled`, `web.carrier`, `web.timeouts`, vhosts, profiles, and decoy snapshots without a process restart. Existing sessions keep their acquisition-time carrier, limits, and deadlines; newly issued bridge sessions use the active generation.
- WEB listener inventory and trust policy under `server.listeners`, and every `web.limits` value, are process-owned and restart-required.
- There is no dedicated `/v1/web` endpoint. `GET /v1/config` omits `[web]`, and `PATCH /v1/config` rejects a `web` key with `400 section_not_editable`.
- To manage WEB policy remotely, update the owned TOML file and call `POST /v1/system/reload`; inspect `GET /v1/system/reload/{id}` and its `deferred_process_fields`. Restart Telemt when it contains `server.listeners` or `web.limits`.
- Existing access users can be created, changed, rotated, enabled, disabled, and deleted through `/v1/users`. Creating a user does not add a WEB profile. Disabling a user immediately updates admission and cancels that user's active sessions.
- `PATCH /v1/config` can persist `server.listeners`, including WEB listener fields, but a changed WEB listener does not become active until process restart.
# [timeouts]
+145
View File
@@ -23,6 +23,12 @@
- [server.conntrack_control](#serverconntrack_control)
- [server.api](#serverapi)
- [server.listeners](#serverlisteners)
- [web](#web)
- [web.limits](#weblimits)
- [web.timeouts](#webtimeouts)
- [web.vhosts](#webvhosts)
- [web.vhosts.decoy](#webvhostsdecoy)
- [web.vhosts.profiles](#webvhostsprofiles)
- [timeouts](#timeouts)
- [censorship](#censorship)
- [censorship.tls_fetch](#censorshiptls_fetch)
@@ -2250,6 +2256,9 @@
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` |
| [`reuse_allow`](#reuse_allow) | `bool` | `false` | `✘` |
| [`transport`](#transport-serverlisteners) | `"mtproxy"` или `"web"` | `"mtproxy"` | `✘` |
| [`web_client_ip_source`](#web_client_ip_source-serverlisteners) | `"x_forwarded_for"` | `"x_forwarded_for"` | `✘` |
| [`web_trusted_proxy_cidrs`](#web_trusted_proxy_cidrs-serverlisteners) | `IpNetwork[]` | `[]` | `✘` |
## ip
- **Ограничения / валидация**: Обязательный параметр. Значение должно содержать IP-адрес в формате строки.
@@ -2443,6 +2452,142 @@
reuse_allow = false
```
## transport (server.listeners)
- **Ограничения / валидация**: `"mtproxy"` или `"web"`.
- **Описание**: Выбирает протокол listener’а. WEB-listener принимает обычный HTTP/1.1 от доверенного TLS-терминатора и требует перезапуска процесса. Для него обязательны `proxy_protocol = false` и `reuse_allow = false`; параметры `client_mss`, `synlimit`, `announce` и `announce_ip` запрещены.
- **Пример**:
```toml
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
```
## web_client_ip_source (server.listeners)
- **Ограничения / валидация**: Первая реализация WEB поддерживает только `"x_forwarded_for"`.
- **Описание**: Выбирает L7-источник исходного IP клиента. Telemt принимает ровно один канонический адрес `X-Forwarded-For`, только если прямой TCP peer входит в `web_trusted_proxy_cidrs`.
## web_trusted_proxy_cidrs (server.listeners)
- **Ограничения / валидация**: Непустой массив CIDR только для WEB. Сеть `/0` запрещена. Параметр недопустим для MTProxy-listener’а.
- **Описание**: Граница доверия для непосредственного NGINX или HAProxy. Указывайте только адреса, которые могут напрямую подключаться к этому listener’у; не публикуйте plain HTTP listener в недоверенной сети.
# [web]
WEB-режим переносит MTProxy-трафик Telegram Desktop внутри HTTPS, который терминирует внешний NGINX или HAProxy. Telemt принимает обычный HTTP/1.1 на приватном listener’е с `transport = "web"`. Перед включением режима прочитайте [полное руководство по развёртыванию WEB](../WEB/WEB_PROXY.ru.md).
| Ключ | Тип | По умолчанию | Hot-Reload |
| --- | --- | --- | --- |
| `enabled` | `bool` | `false` | `✔` |
| `carrier` | `"https"` или `"https-lanes"` | `"https"` | `✔` |
| `limits` | таблица | ограниченные defaults | `✘` |
| `timeouts` | таблица | ограниченные defaults | `✔` |
| `vhosts` | массив таблиц | `[]` | `✔` |
Для `enabled = true` нужен как минимум один доступный по сетевой политике WEB-listener, один vhost и один профиль в каждом vhost. `carrier = "https"` сохраняет сериализованный HTTPS transport. При `carrier = "https-lanes"` stream zero и каждый logical stream получают независимые uplink sequence, downlink cursor, retry и long poll; этот carrier требует `max_http_handlers >= 2` и публичного HTTP/2 на TLS-терминаторе, чтобы убрать application-level inter-stream head-of-line blocking. Reload применяет `carrier` только к новым bridge sessions. Отключение WEB после reload прекращает выдачу новых bridge- и session-credentials; для отзыва активных сессий отдельного пользователя используйте users API.
# [web.limits]
Эти process-wide границы ограничивают все WEB-реестры, очереди, тела запросов, статические snapshots и admission-пути. Значения проверяются совместно: per-owner лимиты не могут превышать глобальные, резервы очередей должны сохранять прогресс control frames, body-резервы должны помещаться в общий бюджет, а все заявленные байтовые границы — в `memory_envelope_bytes`. Изменение любого значения этой таблицы требует перезапуска процесса.
| Ключ | Тип | По умолчанию | Описание |
| --- | --- | --- | --- |
| `max_header_bytes` | `usize` | `16384` | Максимальный размер заголовка одного HTTP-запроса. |
| `max_body_bytes` | `usize` | `2097152` | Максимальный размер собранного carrier body. |
| `max_frame_payload_bytes` | `usize` | `1048576` | Максимальный payload одного WEB frame. |
| `carrier_batch_bytes` | `usize` | `2097152` | Максимальный закодированный downlink batch. |
| `max_frames_per_body` | `usize` | `4096` | Максимальное число frames в одном carrier body. |
| `max_http_connections` | `usize` | `1024` | Принятые WEB HTTP connections на весь процесс. |
| `max_http_handlers` | `usize` | `512` | Одновременно выполняемые HTTP handlers на весь процесс; HTTPS lanes могут занять long polls не более половины лимита, оставляя остаток для session, uplink и control work. |
| `max_body_readers` | `usize` | `32` | Одновременно собираемые request bodies на весь процесс. |
| `max_body_bytes_global` | `usize` | `67108864` | Глобальный байтовый резерв для собранных bodies. |
| `max_sessions_global` | `usize` | `128` | Активные WEB-сессии на весь процесс. |
| `max_sessions_per_ip` | `usize` | `16` | Активные сессии одного forwarded client IP. |
| `max_streams_per_session` | `usize` | `128` | Default активных logical streams на сессию. |
| `max_streams_global` | `usize` | `4096` | Активные logical streams на весь процесс. |
| `max_stream_handshakes` | `usize` | `256` | Одновременные внутренние MTProxy handshakes. |
| `max_tombstones_per_session` | `usize` | `4096` | Закрытые stream IDs, сохраняемые одной сессией. |
| `pending_bytes_per_session` | `usize` | `33554432` | Байты данных и управления в очередях одной сессии. |
| `pending_bytes_global` | `usize` | `536870912` | Байты данных и управления в очередях всего процесса. |
| `pending_items_per_session` | `usize` | `16384` | Элементы данных и управления в очередях одной сессии. |
| `pending_items_global` | `usize` | `262144` | Элементы данных и управления в очередях всего процесса. |
| `control_bytes_per_session` | `usize` | `262144` | Резерв одной сессии только для control frames. |
| `control_bytes_global` | `usize` | `16777216` | Process-wide резерв только для control frames. |
| `max_bootstraps_global` | `usize` | `512` | Активные bootstrap credentials на весь процесс. |
| `max_bootstraps_per_ip` | `usize` | `64` | Активные bootstrap credentials на один client IP. |
| `max_vhosts` | `usize` | `8` | Настроенные WEB virtual hosts. |
| `max_profiles` | `usize` | `32` | WEB-профили всех vhosts. |
| `max_static_files` | `usize` | `4096` | Элементы static snapshot всех vhosts. |
| `max_static_file_bytes` | `usize` | `8388608` | Максимальный размер одного статического файла. |
| `max_static_bytes` | `usize` | `67108864` | Размер static snapshots всех vhosts. |
| `memory_envelope_bytes` | `usize` | `805306368` | Заявленный envelope для HTTP heads, bodies, очередей и static snapshots; максимум 4 GiB. |
| `new_bootstraps_per_minute` | `u32` | `1200` | Устойчивая process-wide скорость выдачи bootstrap. |
| `new_bootstraps_burst` | `u32` | `256` | Process-wide burst выдачи bootstrap. |
| `new_sessions_per_minute` | `u32` | `600` | Устойчивая process-wide скорость создания сессий. |
| `new_sessions_burst` | `u32` | `128` | Process-wide burst создания сессий. |
| `new_streams_per_minute` | `u32` | `6000` | Устойчивая скорость создания logical streams. |
| `new_streams_burst` | `u32` | `512` | Process-wide burst создания logical streams. |
# [web.timeouts]
Все таймауты задаются в секундах и должны входить в диапазон `1..=3600`. Самый длинный request deadline должен быть меньше `http_idle_secs`.
| Ключ | Тип | По умолчанию | Hot-Reload | Описание |
| --- | --- | --- | --- | --- |
| `header_secs` | `u64` | `10` | `✔` | Получение полного заголовка HTTP-запроса. |
| `body_secs` | `u64` | `30` | `✔` | Сбор одного аутентифицированного carrier body. |
| `stream_handshake_secs` | `u64` | `10` | `✔` | Выполнение внутреннего MTProxy handshake. |
| `long_poll_secs` | `u64` | `25` | `✔` | Максимальная длительность пустого downlink long poll. |
| `bootstrap_lifetime_secs` | `u64` | `120` | `✔` | Срок неиспользованного bootstrap и replay-marker закрытого token. |
| `reconnect_grace_secs` | `u64` | `120` | `✔` | Максимальная неактивность carrier до закрытия сессии. |
| `http_idle_secs` | `u64` | `75` | `✔` | Idle lifetime WEB HTTP keep-alive connection. |
| `shutdown_secs` | `u64` | `15` | `✔` | Deadline корректного завершения WEB. |
| `decoy_header_secs` | `u64` | `30` | `✔` | Deadline подключения и получения response head от HTTP decoy. |
# [[web.vhosts]]
| Ключ | Тип | Обязательный | Hot-Reload | Описание |
| --- | --- | --- | --- | --- |
| `host` | `String` | да | `✔` | Уникальный канонический lowercase ACE FQDN без порта, пути, credentials и завершающей точки. |
| `public_addr` | `SocketAddr` | да | `✔` | Конкретный публичный IP на порту `443`, используемый во внутреннем destination tuple relay. |
| `decoy` | таблица | да | `✔` | Обычный сайт для неаутентифицированного или некорректного трафика. |
| `profiles` | массив таблиц | при включённом WEB | `✔` | Явные пользователи и client secret modes для этого hostname. |
Forwarded client address и `public_addr` должны относиться к одному семейству IP. Hostname нормализуется при валидации и должен приниматься Telegram Desktop.
# [web.vhosts.decoy]
Обязателен ровно один decoy mode:
| Mode | Обязательные ключи | Валидация |
| --- | --- | --- |
| `http_upstream` | `upstream` | `http://` origin с loopback, link-local или private IP literal; без credentials, path, query и fragment. |
| `static_directory` | `directory`; необязательный `index = "index.html"` | Абсолютный реальный каталог и одно безопасное имя index-файла. Symlinks и выход за пределы каталога запрещены; immutable snapshot загружается в пределах `[web.limits]`. |
# [[web.vhosts.profiles]]
| Ключ | Тип | Обязательный | По умолчанию | Описание |
| --- | --- | --- | --- | --- |
| `user` | `String` | да | — | Существующий ключ из `[access.users]`. |
| `secret_mode` | `"plain"` или `"dd"` | да | — | Точное представление секрета для Telegram Desktop. `ee` не поддерживается. |
| `max_sessions` | `usize` | нет | `web.limits.max_sessions_global` | Активные сессии этого профиля. |
| `max_streams` | `usize` | нет | `web.limits.max_streams_global` | Активные logical streams этого профиля. |
| `max_streams_per_session` | `usize` | нет | `web.limits.max_streams_per_session` | Активные logical streams в одной сессии профиля. |
Лимиты профиля должны быть ненулевыми и не превышать соответствующие глобальные границы. Повторяющиеся профили `(user, secret_mode)` в одном vhost запрещены.
## Lifecycle WEB и управление через API
- Config watcher и generation reload применяют `web.enabled`, `web.carrier`, `web.timeouts`, vhosts, profiles и decoy snapshots без перезапуска процесса. Существующие сессии сохраняют carrier, лимиты и deadlines своего момента создания; новые bridge sessions используют активное поколение.
- Состав WEB-listeners и их trust policy в `server.listeners`, а также все значения `web.limits` принадлежат процессу и требуют перезапуска.
- Отдельного endpoint `/v1/web` нет. `GET /v1/config` не возвращает `[web]`, а `PATCH /v1/config` отклоняет ключ `web` с `400 section_not_editable`.
- Для удалённого применения WEB policy измените соответствующий TOML-файл и вызовите `POST /v1/system/reload`; проверьте `GET /v1/system/reload/{id}` и поле `deferred_process_fields`. Если оно содержит `server.listeners` или `web.limits`, перезапустите Telemt.
- Существующих access users можно создавать, изменять, ротировать, включать, выключать и удалять через `/v1/users`. Создание пользователя не добавляет WEB-профиль. Отключение пользователя немедленно обновляет admission и завершает его активные сессии.
- `PATCH /v1/config` может сохранить `server.listeners`, включая поля WEB-listener’а, но изменённый WEB-listener активируется только после перезапуска процесса.
# [timeouts]
+277
View File
@@ -0,0 +1,277 @@
# WEB-Proxy-Modus
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS-Carrier, die mit dem Proxy-Typ `WEB` von Telegram Desktop kompatibel sind. Telemt terminiert TLS nicht selbst: NGINX oder HAProxy verwaltet das öffentliche Zertifikat und leitet unverschlüsseltes HTTP/1.1 an einen privaten Telemt-Listener weiter.
> [!IMPORTANT]
>
> Der WEB-Modus ist im aktuellen Quellcode implementiert und konfigurierbar. Für die erste Bereitstellung sind ein Binary aus einer Revision mit dieser Implementierung und ein Neustart des Telemt-Prozesses erforderlich. Veröffentlichte Pakete dürfen erst verwendet werden, nachdem geprüft wurde, dass sie dieselbe Revision enthalten. Die Ende-zu-Ende-Prüfung mit dem vorgesehenen Telegram-Desktop-Build und dem realen öffentlichen TLS-Endpunkt bleibt ein Abnahmeschritt des Betreibers.
## Datenpfad
```text
Telegram Desktop
| HTTPS :443
v
NGINX oder HAProxy (TLS-Terminierung, kanonische Werte für Host und X-Forwarded-For)
| unverschlüsseltes HTTP/1.1 in einem privaten Netz
v
Telemt-WEB-Listener
|-- authentifizierter Carrier --> begrenzte logische MTProxy-Relays --> Telegram
`-- gewöhnlicher oder ungültiger Request --> konfigurierte Decoy-Site
```
Leiten Sie den vollständigen öffentlichen vhost an Telemt weiter. Wenn der TLS-Terminator nur bekannte Carrier-Pfade trennt, unterscheiden sich gewöhnliches und authentifiziertes Verhalten beobachtbar und Telemt kann seine Decoy-Richtlinie nicht durchsetzen.
## Unterstützter Client-Vertrag
- Der öffentliche Endpunkt ist immer `https://HOST:443`.
- Unterstützt werden 16-Byte-MTProxy-Secrets in den Modi `plain` und `dd`. FakeTLS-Secrets mit `ee` werden im WEB-Modus nicht unterstützt.
- `web.carrier = "https"` wählt serialisierte HTTPS-Uplinks und Long Polling. `web.carrier = "https-lanes"` wählt unabhängige HTTPS-Sequenzen und Polls pro logischem Stream. WebSocket-Carrier werden nicht angeboten.
- Capability-, Bootstrap- und Session-Zugangsdaten sind getrennte Werte mit begrenzter Lebensdauer. Carrier-Zugangsdaten sind geheim und dürfen nicht in Access-Logs erscheinen.
- 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:
```text
tg://webproxy?server=proxy.example.com&secret=0123456789abcdef0123456789abcdef
tg://webproxy?server=proxy.example.com&secret=dd0123456789abcdef0123456789abcdef
```
Telemt gibt Links für die durch `[general.links].show` ausgewählten WEB-Profile über das vorhandene Log-Target `telemt::links` aus.
## Voraussetzungen
- Ein eigener öffentlicher FQDN und ein gültiges TLS-Zertifikat auf NGINX oder HAProxy.
- Eine stabile öffentliche IP für diesen Hostnamen. `public_addr` muss genau diese konkrete IP auf Port 443 enthalten, da die Adresse Teil des Ziel-Tupels des inneren Relays ist.
- Ein privater oder lokaler HTTP-Pfad vom TLS-Terminator zu Telemt.
- Eine gewöhnliche Decoy-Site als privater HTTP-Origin oder unveränderlicher Snapshot eines lokalen Verzeichnisses.
- Ein kompatibler Telegram-Desktop-Build mit dem Proxy-Typ `WEB`.
Wenn ein Hostname sowohl über IPv4 als auch IPv6 bedient wird, verwenden Sie in dieser ersten Implementierung getrennte Hostnamen oder Telemt-Instanzen. Die weitergeleitete Client-Adresse und `public_addr` müssen dieselbe IP-Familie verwenden.
## Minimale Telemt-Konfiguration
Das Beispiel bindet den WEB-Listener an Loopback und verwendet einen privaten HTTP-Decoy-Origin:
```toml
[general.links]
show = ["web-user"]
[access.users]
web-user = "0123456789abcdef0123456789abcdef"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
carrier = "https-lanes"
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "web-user"
secret_mode = "dd"
max_sessions = 8
max_streams = 512
max_streams_per_session = 64
```
`https` bleibt der Default und behält das ursprüngliche serialisierte Verhalten bei. Bei `https-lanes` ist Lane null für Session-Steuerung reserviert, und jeder logische Stream ungleich null erhält eine eigene Lane. Jede Lane besitzt eigene Uplink-Sequenzen, Retry-Digests, Downlink-Cursor, nicht bestätigte Replay-Batches, Queues und einen Newest-Poll-Wins-Lebenszyklus. Ein langsamer Stream blockiert daher keinen anderen Stream auf der WEB-Protokollebene.
Damit entfällt die Serialisierung zwischen WEB-Streams auf Anwendungsebene. Öffentliches HTTP/2 läuft weiterhin über eine oder mehrere TCP-Verbindungen, sodass Paketverlust Head-of-Line-Blocking auf Transportebene verursachen kann; `https-lanes` ist kein HTTP/3- oder QUIC-Carrier.
Alle Lane-Queues bleiben innerhalb der vorhandenen Byte-/Item-Budgets pro Sitzung und Prozess. Die Bridge begrenzt jede Lane zusätzlich auf 8 MiB und 1024 eingereihte Elemente. Lane-Long-Polls dürfen höchstens die Hälfte von `web.limits.max_http_handlers` belegen, sodass Handler-Kapazität für Sitzungserstellung, Uplink, DELETE und andere Steuerarbeit verbleibt. `https-lanes` erfordert `max_http_handlers >= 2`.
Die Pfade `/api/v1/up` und `/api/v1/down` ändern sich nicht. Bei `https-lanes` enthält jeder Request an diese Pfade genau einen kanonischen dezimalen `X-Lane-ID`-Header. Die Uplink-Sequenz beginnt pro Lane unabhängig bei `1`, der Downlink-Cursor bei `0`. Lane null akzeptiert nur Session-`PONG`; jeder Frame einer Lane ungleich null muss dieselbe Stream-ID tragen, und eine neue Lane muss mit `OPEN` beginnen. Nachdem eingereihte und nicht bestätigte Downlink-Daten einer geschlossenen Lane vollständig abgearbeitet sind, antwortet Telemt leer mit `X-Lane-Closed: 1`, und die Bridge beendet deren Polling. Wiederholungen bleiben byte-identisch und spielen die ursprüngliche Bestätigung oder den Downlink-Batch erneut aus.
Der WEB-Listener muss `proxy_protocol = false` und `reuse_allow = false` verwenden. `client_mss`, `synlimit`, `announce` und `announce_ip` sind nicht zulässig. `web_trusted_proxy_cidrs` muss nicht leer sein und darf nur die unmittelbar vorgeschalteten NGINX- oder HAProxy-Peers enthalten; `/0`-Netze werden abgelehnt.
Der HTTP-Decoy-Origin muss eine Loopback-, Link-Local- oder private IP-Adresse als Literal verwenden. Telemt bewahrt bei gewöhnlichen Requests Methode, Pfad, Query, Header, gestreamten Body, Response-Status, Header und Body und entfernt Hop-by-Hop-Header. Vor dem Fallback auf den Decoy entfernt Telemt Carrier-Zugangsdaten und Bodys aus fehlerhaften Carrier-Requests.
Alternativ kann ein unveränderlicher Snapshot einer statischen Site verwendet werden:
```toml
[web.vhosts.decoy]
mode = "static_directory"
directory = "/var/lib/telemt/public"
index = "index.html"
```
Statische Dateien werden beim Start und bei einem erfolgreichen Konfigurations-Reload gelesen. Eintragszahl, Dateigröße und Gesamtgröße des Snapshots werden durch `[web.limits]` begrenzt. Symlinks und Pfade außerhalb des konfigurierten Verzeichnisses werden abgelehnt. Ändern Sie das Verzeichnis nicht gleichzeitig, während Telemt einen Snapshot erstellt.
Alle WEB-Schlüssel und Defaults sind in der [Konfigurationsreferenz](../Config_params/CONFIG_PARAMS.de.md#web) aufgeführt.
## TLS-Terminierung mit NGINX
```nginx
upstream telemt_web {
server 127.0.0.1:18080;
keepalive 64;
}
server {
listen 443 ssl;
http2 on;
server_name proxy.example.com;
access_log off;
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
client_max_body_size 2m;
location / {
proxy_pass http://telemt_web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 35s;
proxy_read_timeout 35s;
proxy_request_buffering off;
proxy_buffering off;
proxy_next_upstream off;
}
}
```
`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.
Ö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.
## TLS-Terminierung mit HAProxy
```haproxy
frontend public_https
mode http
no log
bind :443 ssl crt /etc/haproxy/certs/proxy.example.com.pem alpn h2,http/1.1
acl telemt_web_host hdr(host) -i proxy.example.com proxy.example.com:443
use_backend telemt_web if telemt_web_host
backend telemt_web
mode http
option http-keep-alive
retries 0
timeout connect 5s
timeout server 35s
http-request set-header Host proxy.example.com
http-request del-header X-Forwarded-For
http-request set-header X-Forwarded-For %[src]
server telemt_web_1 127.0.0.1:18080 check
```
Im Frontend oder im Abschnitt `defaults` muss auch `timeout client` oberhalb der Long-Poll-Deadline liegen. Für `https-lanes` muss das öffentliche HAProxy-ALPN `h2` enthalten. Pfad, Raw Query, Body sowie die Carrier-Header `Authorization`, `Content-Type`, `X-Up-Seq`, `X-Down-Cursor` und `X-Lane-ID` dürfen nicht umgeschrieben werden.
## Lebenszyklus und Reload-Verhalten
| Konfiguration | Runtime-Verhalten |
| --- | --- |
| Bestand der WEB-Listener, Bind-Adresse und Vertrauensrichtlinie | Prozesseigen; Telemt neu starten. |
| Jeder Wert in `[web.limits]` | Prozesseigener Speicher- und Ressourcenvertrag; Telemt neu starten. |
| `web.enabled`, `web.carrier`, Timeouts, vhosts, Profile und Decoys | Werden vom Config-Watcher oder durch einen Runtime-Generations-Reload angewendet. |
| Bestehende HTTP-Verbindungen und WEB-Sitzungen | Behalten Carrier, Grenzen und Deadlines ihres Erstellungszeitpunkts; neu ausgegebene Bridge-Sitzungen verwenden den aktiven Carrier. Neue logische Streams verwenden die aktive Relay-Generation. |
| Beenden des Prozesses | Verwendet den zuletzt geladenen Wert von `web.timeouts.shutdown_secs`. |
Jeder logische Stream behält die Client-IP seiner Sitzung und besitzt während der gesamten Relay-Lebensdauer einen prozessweit eindeutigen, von null verschiedenen synthetischen Quellport. Damit bleibt für Direct- und Middle-End-KDF-Routing ein stabiles, kollisionsfreies Quell-/Ziel-Tupel erhalten.
## Verwaltung über die API
API-Verwaltung ist verfügbar, aber absichtlich eingeschränkt. Es gibt weder einen eigenen Endpunkt `/v1/web` noch einen WEB-spezifischen Runtime-Statistik-Endpunkt.
| Operation | API-Unterstützung |
| --- | --- |
| `[web]`, vhosts, Profile, Decoys, Timeouts oder Limits lesen oder ändern | Nein. `GET /v1/config` lässt `[web]` aus; `PATCH /v1/config` antwortet für `web` mit `400 section_not_editable`. |
| `server.listeners` speichern | Ja, über `PATCH /v1/config`; ein geänderter WEB-Listener bleibt jedoch bis zum Prozessneustart zurückgestellt. |
| Außerhalb der API geänderte WEB-Konfiguration anwenden | Ja, über `POST /v1/system/reload` und anschließende Abfrage des Vorgangsstatus. |
| `[access.users]` verwalten | Ja, über `/v1/users`. Das Erstellen eines Benutzers erzeugt kein WEB-Profil. |
| Einen Benutzer widerrufen | Ja. `/v1/users/{username}/disable` aktualisiert die Admission sofort und beendet die aktiven Sitzungen dieses Benutzers. |
Binden Sie die API an Loopback, halten Sie die Whitelist direkter Peers eng, konfigurieren Sie einen exakten Authorization-Header und verwenden Sie `read_only = false` nur dort, wo Mutationen erforderlich sind:
```toml
[server.api]
enabled = true
listen = "127.0.0.1:9091"
whitelist = ["127.0.0.0/8"]
auth_header = "Bearer replace-with-a-random-control-token"
read_only = false
```
Die API-Whitelist prüft den direkten TCP-Peer und vertraut `X-Forwarded-For` nicht. Änderungen an `[server.api]` selbst erfordern einen Prozessneustart.
Nachdem ein Administrator oder Konfigurationssystem die TOML-Datei atomar aktualisiert hat, setzen Sie `TELEMT_API_AUTH` auf den exakten Wert von `auth_header` und starten Sie einen beobachtbaren Generations-Reload:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/system/reload \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{"mode":"drain","timeout_secs":30,"failure_policy":"rollback"}'
# Use data.reload_id from the response.
curl -sS http://127.0.0.1:9091/v1/system/reload/RELOAD_ID \
-H "Authorization: ${TELEMT_API_AUTH}"
```
Der terminale Status `succeeded` bestätigt die Runtime-Aktivierung. Ein geänderter `web.carrier` wird von neu ausgegebenen Bridge-Sitzungen verwendet; bestehende Sitzungen werden nicht migriert. Enthält `deferred_process_fields` den Wert `server.listeners` oder `web.limits`, ist die Datei gültig und gespeichert, diese Einstellungen erfordern aber weiterhin einen Telemt-Neustart.
Operationen für Access-Benutzer verwenden die vorhandenen Endpunkte, zum Beispiel:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/disable \
-H "Authorization: ${TELEMT_API_AUTH}"
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{}'
```
Nach einer Secret-Rotation erstellt der Config-Watcher die WEB-Capabilities neu. Die Users-API liefert das Secret, aber keine `tg://webproxy`-URL. Erstellen Sie den Link mit dem konfigurierten Hostnamen und der `plain`- oder `dd`-Darstellung des Profils. Entfernen und aktivieren Sie vor dem Löschen eines Benutzers zuerst das WEB-Profil, das auf ihn verweist, damit die resultierende Konfiguration gültig bleibt.
Der vollständige Vertrag für Requests, Revisionen, Fehler und alle Benutzer-Endpunkte steht in der [Dokumentation der Control API](../Architecture/API/API.md).
## Bereitstellungsinvarianten
- Veröffentlichen Sie den unverschlüsselten HTTP-WEB-Listener niemals in einem nicht vertrauenswürdigen Netz. Erzwingen Sie diese Einschränkung auch bei einer Loopback-Bindung mit Host-Firewall-Regeln.
- Deaktivieren Sie am TLS-Terminator die Protokollierung von Request-Target und Authorization oder verwenden Sie ein geprüftes, redigiertes Format. Raw Queries enthalten Bridge-Capabilities und `Authorization` enthält Bootstrap- oder Session-Bearer-Zugangsdaten.
- Verwenden Sie pro vhost eine stabile öffentliche Adresse. Wenn DNS mehrere Ingress-Adressen liefert, muss jede Bereitstellung die Adresse ihres externen Pfads verwenden.
- Bootstrap- und Session-Register sind prozesslokal. Ein Multi-Prozess- oder Multi-Host-Upstream-Pool benötigt Affinität für den vollständigen vhost: Bridge-GET, Sitzungserstellung, Uplink, Downlink und DELETE. Ein einzelner Telemt-Prozess benötigt keine zusätzliche Affinität.
- Der Decoy gehört zum Anti-Probing-Vertrag. Prüfen Sie sein gewöhnliches 404-Verhalten und die Antwortzeiten über den öffentlichen TLS-Endpunkt, bevor Sie Links verteilen.
## Erstprüfung
1. Starten Sie das neu erstellte Telemt-Binary mit der WEB-Konfiguration und prüfen Sie, dass der private Listener gebunden ist.
2. Prüfen Sie über den öffentlichen TLS-Endpunkt, dass `GET /`, ein unbekannter Pfad und eine ungültige `bridge`-Query die konfigurierte Decoy-Site zurückgeben.
3. Prüfen Sie, dass Telemt genau eine kanonische `X-Forwarded-For`-Adresse und `Host: proxy.example.com` oder `Host: proxy.example.com:443` erhält.
4. Importieren Sie den ausgegebenen `tg://webproxy`-Link in den vorgesehenen Telegram-Desktop-Build und stellen Sie eine Proxy-Verbindung her.
5. Bestätigen Sie für `https-lanes`, dass die öffentliche Verbindung HTTP/2 ausgehandelt hat, und testen Sie mindestens zwei gleichzeitige logische Streams; der private Hop zu Telemt bleibt HTTP/1.1.
6. Testen Sie einen Reconnect und mindestens einen Long Poll über 25 Sekunden, um sicherzustellen, dass Frontend-Timeouts den Carrier nicht abbrechen.
7. Prüfen Sie Benutzer- und logische MTProxy-Verbindungslimits anhand der Logical-Stream-Zähler und nicht anhand der Zahl der HTTP-Verbindungen.
## Fehlerbehebung
| Symptom | Prüfung |
| --- | --- |
| WEB-Konfiguration ist auf dem Datenträger gültig, aber das Listener-Verhalten hat sich nicht geändert | Prüfen Sie `deferred_process_fields`; Listener- und `[web.limits]`-Änderungen erfordern einen Neustart. |
| Carrier-Requests erreichen den Decoy | Prüfen Sie den exakten vhost, den Secret-Modus des Links, das CIDR des direkten Proxys und genau einen kanonischen `X-Forwarded-For`-Wert. |
| Long Polls werden nach einem festen Intervall getrennt | Setzen Sie Client-, Server-, Sende- und Lese-Timeouts von NGINX/HAProxy über `web.timeouts.long_poll_secs`. |
| `https-lanes` funktioniert, Streams blockieren sich aber weiterhin | Prüfen Sie die öffentliche HTTP/2-Aushandlung, die unveränderte Weitergabe von `X-Lane-ID` und genügend TLS-Terminator-Upstream-Verbindungen für parallele private HTTP/1.1-Polls. |
| Telegram Desktop lehnt den Link ab | Lassen Sie den Port weg und verwenden Sie einen gültigen FQDN, extern Port 443 sowie ausschließlich `plain` oder `dd`. |
| Ein Knoten funktioniert, ein Load-Balancing-Pool aber nur sporadisch | Konfigurieren Sie Affinität für den gesamten vhost; WEB-Zugangsdatenregister sind prozesslokal. |
+277
View File
@@ -0,0 +1,277 @@
# WEB proxy mode
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
WEB mode carries ordinary MTProxy streams through bounded HTTPS carriers compatible with Telegram Desktop's `WEB` proxy type. Telemt does not terminate TLS: NGINX or HAProxy owns the public certificate and forwards plain HTTP/1.1 to a private Telemt listener.
> [!IMPORTANT]
>
> WEB mode is implemented and configurable in the current source tree. The first deployment requires a binary built from a revision containing this implementation and a Telemt process restart. Published packages can be used only after verifying that they contain the same revision. End-to-end validation with the intended Telegram Desktop build and the real public TLS endpoint remains an operator acceptance step.
## Traffic path
```text
Telegram Desktop
| HTTPS :443
v
NGINX or HAProxy (TLS termination, canonical Host and X-Forwarded-For)
| plain HTTP/1.1 on a private network
v
Telemt WEB listener
|-- authenticated carrier --> bounded logical MTProxy relays --> Telegram
`-- ordinary or invalid request --> configured decoy site
```
Route the complete public vhost to Telemt. Splitting only recognized carrier paths at the TLS terminator would make ordinary and authenticated behavior observably different and would bypass Telemt's decoy policy.
## Supported client contract
- The public endpoint is always `https://HOST:443`.
- `plain` and `dd` 16-byte MTProxy secrets are supported. `ee` FakeTLS secrets are not supported by WEB mode.
- `web.carrier = "https"` selects serialized HTTPS uplink and long polling. `web.carrier = "https-lanes"` selects independent HTTPS sequencing and polling per logical stream. WebSocket carriers are not advertised.
- Capability, bootstrap, and session credentials are separate bounded-lifetime values. Carrier credentials must be treated as secrets and must not appear in access logs.
- 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:
```text
tg://webproxy?server=proxy.example.com&secret=0123456789abcdef0123456789abcdef
tg://webproxy?server=proxy.example.com&secret=dd0123456789abcdef0123456789abcdef
```
Telemt prints links for WEB profiles selected by `[general.links].show` through the existing `telemt::links` log target.
## Prerequisites
- A dedicated public FQDN and valid TLS certificate on NGINX or HAProxy.
- A stable public IP for that hostname. `public_addr` must be that concrete IP on port 443 because it participates in the inner relay destination tuple.
- A private or loopback HTTP path from the TLS terminator to Telemt.
- A normal decoy site, either a private HTTP origin or an immutable local directory snapshot.
- A compatible Telegram Desktop build with the `WEB` proxy type.
If one hostname is served through both IPv4 and IPv6, use separate Telemt deployments or separate hostnames in this first implementation. The forwarded client address and `public_addr` must use the same IP family.
## Minimal Telemt configuration
The example keeps the WEB listener on loopback and uses a private HTTP decoy origin:
```toml
[general.links]
show = ["web-user"]
[access.users]
web-user = "0123456789abcdef0123456789abcdef"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
carrier = "https-lanes"
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "web-user"
secret_mode = "dd"
max_sessions = 8
max_streams = 512
max_streams_per_session = 64
```
`https` remains the default and preserves the original serialized behavior. `https-lanes` assigns lane zero to session control and one lane to every non-zero logical stream. Each lane has its own uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, queue, and newest-poll-wins lifecycle. A slow stream therefore does not block another stream at the WEB protocol layer.
This removes application-level serialization between WEB streams. Public HTTP/2 still runs over one or more TCP connections, so packet loss can cause transport-level head-of-line blocking; `https-lanes` is not an HTTP/3 or QUIC carrier.
All lane queues remain inside the existing per-session and process-wide byte/item budgets. The bridge also limits each lane to 8 MiB and 1024 queued items. Telemt permits lane long polls to occupy at most half of `web.limits.max_http_handlers`, preserving handler capacity for session creation, uplink, DELETE, and other control work. `https-lanes` requires `max_http_handlers >= 2`.
The `/api/v1/up` and `/api/v1/down` paths do not change. In `https-lanes`, every request on those paths carries one canonical decimal `X-Lane-ID`. Uplink sequence starts at `1` and downlink cursor at `0` independently for each lane. Lane zero accepts only session `PONG`; every frame in a non-zero lane must have the same stream ID, and a new lane must begin with `OPEN`. After a closed lane's queued and unacknowledged downlink data is drained, Telemt returns an empty response with `X-Lane-Closed: 1`, and the bridge stops polling it. Retries remain byte-identical and replay the original acknowledgement or downlink batch.
The WEB listener must use `proxy_protocol = false` and `reuse_allow = false`. It cannot use `client_mss`, `synlimit`, `announce`, or `announce_ip`. `web_trusted_proxy_cidrs` must be non-empty and must contain only the immediate NGINX or HAProxy peers; `/0` networks are rejected.
The HTTP decoy origin must be a loopback, link-local, or private IP literal. Telemt preserves ordinary request method, path, query, headers, streamed body, response status, headers, and body while removing hop-by-hop headers. Malformed carrier requests have carrier credentials and bodies removed before falling back to the decoy.
An immutable static-site snapshot can be used instead:
```toml
[web.vhosts.decoy]
mode = "static_directory"
directory = "/var/lib/telemt/public"
index = "index.html"
```
Static files are read at startup and successful configuration reload. Entry count, per-file size, and total snapshot size are bounded by `[web.limits]`. Symlinks and paths escaping the configured directory are rejected. Do not mutate the directory concurrently while Telemt builds a snapshot.
All WEB keys and defaults are listed in the [configuration reference](../Config_params/CONFIG_PARAMS.en.md#web).
## NGINX TLS termination
```nginx
upstream telemt_web {
server 127.0.0.1:18080;
keepalive 64;
}
server {
listen 443 ssl;
http2 on;
server_name proxy.example.com;
access_log off;
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
client_max_body_size 2m;
location / {
proxy_pass http://telemt_web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 35s;
proxy_read_timeout 35s;
proxy_request_buffering off;
proxy_buffering off;
proxy_next_upstream off;
}
}
```
`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.
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.
## HAProxy TLS termination
```haproxy
frontend public_https
mode http
no log
bind :443 ssl crt /etc/haproxy/certs/proxy.example.com.pem alpn h2,http/1.1
acl telemt_web_host hdr(host) -i proxy.example.com proxy.example.com:443
use_backend telemt_web if telemt_web_host
backend telemt_web
mode http
option http-keep-alive
retries 0
timeout connect 5s
timeout server 35s
http-request set-header Host proxy.example.com
http-request del-header X-Forwarded-For
http-request set-header X-Forwarded-For %[src]
server telemt_web_1 127.0.0.1:18080 check
```
The frontend or `defaults` section must also set `timeout client` above the long-poll deadline. HAProxy's public ALPN must include `h2` for `https-lanes`. Do not rewrite the path, raw query, body, or the `Authorization`, `Content-Type`, `X-Up-Seq`, `X-Down-Cursor`, and `X-Lane-ID` carrier headers.
## Lifecycle and reload behavior
| Configuration | Runtime behavior |
| --- | --- |
| WEB listener inventory, bind address, and trust policy | Process-owned; restart Telemt. |
| Any `[web.limits]` value | Process-owned memory/resource contract; restart Telemt. |
| `web.enabled`, `web.carrier`, timeouts, vhosts, profiles, and decoys | Applied by the config watcher or a runtime generation reload. |
| Existing HTTP connections and WEB sessions | Keep their acquisition-time carrier, limits, and deadlines; newly issued bridge sessions use the active carrier. New logical streams use the active relay generation. |
| Process shutdown | Uses the latest reloaded `web.timeouts.shutdown_secs`. |
Each logical stream keeps its session's creation-time client IP and owns a process-unique, non-zero synthetic source port for the complete relay lifetime. This preserves one stable, non-colliding source/destination tuple for Direct and Middle-End KDF routing.
## API management
API management is available, but it is intentionally partial. There is no dedicated `/v1/web` endpoint and no WEB-specific runtime statistics endpoint.
| Operation | API support |
| --- | --- |
| Read or patch `[web]`, vhosts, profiles, decoys, timeouts, or limits | No. `GET /v1/config` omits `[web]`; `PATCH /v1/config` returns `400 section_not_editable` for `web`. |
| Persist `server.listeners` | Yes, through `PATCH /v1/config`, but a changed WEB listener remains deferred until process restart. |
| Apply an externally edited WEB configuration | Yes, through `POST /v1/system/reload`, then inspect the operation status. |
| Manage `[access.users]` | Yes, through `/v1/users`. User creation does not create a WEB profile. |
| Revoke one user | Yes. `/v1/users/{username}/disable` updates admission immediately and cancels that user's active sessions. |
Bind the API to loopback, keep its direct-peer whitelist narrow, configure an exact authorization header, and leave `read_only = false` only when mutation is required:
```toml
[server.api]
enabled = true
listen = "127.0.0.1:9091"
whitelist = ["127.0.0.0/8"]
auth_header = "Bearer replace-with-a-random-control-token"
read_only = false
```
The API whitelist checks the direct TCP peer and does not trust `X-Forwarded-For`. Changes to `[server.api]` itself require a process restart.
After an administrator or configuration system atomically updates the TOML file, set `TELEMT_API_AUTH` to the exact value configured in `auth_header` and submit an observable generation reload:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/system/reload \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{"mode":"drain","timeout_secs":30,"failure_policy":"rollback"}'
# Use data.reload_id from the response.
curl -sS http://127.0.0.1:9091/v1/system/reload/RELOAD_ID \
-H "Authorization: ${TELEMT_API_AUTH}"
```
A terminal `succeeded` status confirms runtime activation. A changed `web.carrier` is used by newly issued bridge sessions; existing sessions are not migrated. If `deferred_process_fields` contains `server.listeners` or `web.limits`, the file is valid and persisted but those settings still require a Telemt restart.
Access-user operations use the existing endpoints, for example:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/disable \
-H "Authorization: ${TELEMT_API_AUTH}"
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{}'
```
The config watcher rebuilds WEB capabilities after a secret rotation. The users API returns the secret, not a `tg://webproxy` URL; construct the link with the configured hostname and the profile's `plain` or `dd` representation. Before deleting a user referenced by a WEB profile, remove and apply that profile first so the resulting configuration remains valid.
See the complete [Control API contract](../Architecture/API/API.md) for request envelopes, revisions, failure modes, and all user endpoints.
## Deployment invariants
- Never expose the plain HTTP WEB listener to an untrusted network. Enforce the restriction with host firewall rules even when it binds to loopback.
- Disable request-target and authorization logging at the TLS terminator, or use a verified redacted format. Raw queries contain bridge capabilities and `Authorization` contains bootstrap or session bearer credentials.
- Keep one stable public address per vhost. If DNS returns several ingress addresses, each deployment must use the address matching its external path.
- Bootstrap and session registries are process-local. A multi-process or multi-host upstream pool requires affinity for the complete vhost: bridge GET, session creation, uplink, downlink, and DELETE. A single Telemt process needs no extra affinity.
- The decoy is part of the anti-probing contract. Verify its ordinary 404 behavior and response timing through the public TLS endpoint before distributing links.
## Initial verification
1. Start the rebuilt Telemt binary with the WEB configuration and confirm that the private listener is bound.
2. Confirm through the public TLS endpoint that `GET /`, an unknown path, and an invalid `bridge` query return the configured decoy site.
3. Confirm that Telemt receives one canonical `X-Forwarded-For` address and `Host: proxy.example.com` or `Host: proxy.example.com:443`.
4. Import the printed `tg://webproxy` link in the intended Telegram Desktop build and establish a proxy connection.
5. For `https-lanes`, confirm that the public connection negotiated HTTP/2 and exercise at least two simultaneous logical streams; the private Telemt hop remains HTTP/1.1.
6. Exercise reconnect and at least one long poll beyond 25 seconds to prove the frontend timeouts do not truncate the carrier.
7. Verify user and logical MTProxy connection limits using logical-stream counters, not the number of HTTP connections.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| WEB configuration is valid on disk but listener behavior did not change | Inspect reload `deferred_process_fields`; listener and `[web.limits]` changes require restart. |
| Carrier requests reach the decoy | Verify exact vhost, link secret mode, direct proxy CIDR, and one canonical `X-Forwarded-For` value. |
| Long polls disconnect near a fixed interval | Raise NGINX/HAProxy client, server, send, and read timeouts above `web.timeouts.long_poll_secs`. |
| `https-lanes` works but streams still block each other | Confirm public HTTP/2 negotiation, preserve `X-Lane-ID`, and provide enough TLS-terminator upstream connections for concurrent private HTTP/1.1 polls. |
| Telegram Desktop rejects the link | Omit the port, use a valid FQDN, port 443 externally, and only `plain` or `dd` secret mode. |
| One node works but a load-balanced pool is intermittent | Add complete-vhost affinity; WEB credential registries are process-local. |
+277
View File
@@ -0,0 +1,277 @@
# WEB-режим прокси
[English](WEB_PROXY.en.md) | [Русский](WEB_PROXY.ru.md) | [Deutsch](WEB_PROXY.de.md)
WEB-режим переносит обычные MTProxy-потоки через bounded HTTPS carriers, совместимые с типом прокси `WEB` в Telegram Desktop. Telemt не терминирует TLS: публичный сертификат обслуживает NGINX или HAProxy, который передаёт обычный HTTP/1.1 на приватный listener Telemt.
> [!IMPORTANT]
>
> WEB-режим реализован и настраивается в текущем дереве исходного кода. Для первого развёртывания нужен бинарный файл, собранный из ревизии с этой реализацией, и перезапуск процесса Telemt. Готовый пакет можно использовать только после проверки, что он содержит эту ревизию. Сквозная проверка с целевой сборкой Telegram Desktop и реальным публичным TLS endpoint остаётся обязательным приёмочным шагом оператора.
## Путь трафика
```text
Telegram Desktop
| HTTPS :443
v
NGINX или HAProxy (TLS termination, канонические Host и X-Forwarded-For)
| обычный HTTP/1.1 в приватной сети
v
WEB-listener Telemt
|-- аутентифицированный carrier --> bounded logical MTProxy relays --> Telegram
`-- обычный или некорректный запрос --> настроенный decoy site
```
Направляйте в Telemt весь публичный vhost. Если TLS-терминатор будет выделять только известные carrier paths, поведение обычных и аутентифицированных запросов станет наблюдаемо различным, а decoy policy Telemt будет обойдена.
## Поддерживаемый контракт клиента
- Публичный endpoint всегда имеет вид `https://HOST:443`.
- Поддерживаются 16-байтовые MTProxy-секреты `plain` и `dd`. FakeTLS-секреты `ee` в WEB-режиме не поддерживаются.
- `web.carrier = "https"` выбирает сериализованные HTTPS uplink и long polling. `web.carrier = "https-lanes"` выбирает независимые HTTPS sequencing и polling для каждого logical stream. WebSocket carriers не анонсируются.
- Capability, bootstrap и session credentials — отдельные значения с ограниченным сроком жизни. Carrier credentials считаются секретами и не должны попадать в access logs.
- Внутренняя MTProxy-аутентификация ограничена пользователем и режимом секрета, выбранными профилем vhost. Некорректный внутренний handshake закрывает только свой logical stream и никогда не попадает в TCP masking path.
В WEB-ссылках Telegram Desktop нет порта, потому что клиент требует порт 443:
```text
tg://webproxy?server=proxy.example.com&secret=0123456789abcdef0123456789abcdef
tg://webproxy?server=proxy.example.com&secret=dd0123456789abcdef0123456789abcdef
```
Telemt печатает ссылки для WEB-профилей, выбранных в `[general.links].show`, через существующий log target `telemt::links`.
## Предварительные требования
- Отдельный публичный FQDN и действующий TLS-сертификат на NGINX или HAProxy.
- Стабильный публичный IP этого hostname. В `public_addr` должен быть указан именно этот конкретный IP с портом 443, поскольку адрес участвует во внутреннем destination tuple relay.
- Приватный или loopback HTTP-путь от TLS-терминатора до Telemt.
- Обычный decoy site: приватный HTTP origin либо immutable snapshot локального каталога.
- Совместимая сборка Telegram Desktop с типом прокси `WEB`.
Если один hostname обслуживается одновременно по IPv4 и IPv6, в первой реализации используйте отдельные hostname или отдельные экземпляры Telemt. Forwarded client address и `public_addr` должны принадлежать одному семейству IP.
## Минимальная конфигурация Telemt
В примере WEB-listener остаётся на loopback, а decoy использует приватный HTTP origin:
```toml
[general.links]
show = ["web-user"]
[access.users]
web-user = "0123456789abcdef0123456789abcdef"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
carrier = "https-lanes"
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "web-user"
secret_mode = "dd"
max_sessions = 8
max_streams = 512
max_streams_per_session = 64
```
`https` остаётся default и сохраняет исходное сериализованное поведение. В `https-lanes` lane zero отведена под session control, а каждому ненулевому logical stream соответствует своя lane. У каждой lane собственные uplink sequence, retry digest, downlink cursor, unacknowledged replay batch, очередь и lifecycle newest-poll-wins. Поэтому медленный stream не блокирует другой stream на уровне WEB-протокола.
Это устраняет сериализацию между WEB-streams на уровне приложения. Публичный HTTP/2 всё ещё работает поверх одного или нескольких TCP-connections, поэтому потеря пакетов может вызвать transport-level head-of-line blocking; `https-lanes` не является HTTP/3- или QUIC-carrier.
Все lane queues входят в существующие per-session и process-wide byte/item budgets. Bridge дополнительно ограничивает одну lane 8 MiB и 1024 элементами. Lane long polls могут занимать не более половины `web.limits.max_http_handlers`, оставляя handler capacity для session creation, uplink, DELETE и другой control work. Для `https-lanes` требуется `max_http_handlers >= 2`.
Paths `/api/v1/up` и `/api/v1/down` не меняются. В `https-lanes` каждый запрос к ним содержит один канонический десятичный `X-Lane-ID`. Uplink sequence начинается с `1`, а downlink cursor — с `0` независимо для каждой lane. Lane zero принимает только session `PONG`; все frames ненулевой lane должны иметь тот же stream ID, а новая lane должна начинаться с `OPEN`. После отправки всей queued и unacknowledged downlink data закрытой lane Telemt возвращает пустой ответ с `X-Lane-Closed: 1`, и bridge прекращает её polling. Retry остаются byte-identical и повторяют исходный acknowledgement или downlink batch.
Для WEB-listener обязательны `proxy_protocol = false` и `reuse_allow = false`. В нём нельзя использовать `client_mss`, `synlimit`, `announce` и `announce_ip`. Массив `web_trusted_proxy_cidrs` должен быть непустым и содержать только непосредственные адреса NGINX или HAProxy; сети `/0` запрещены.
HTTP decoy origin должен быть loopback, link-local или private IP literal. Для обычных запросов Telemt сохраняет method, path, query, headers, streamed body, response status, headers и body, удаляя hop-by-hop headers. Перед отправкой некорректного carrier-запроса в decoy Telemt удаляет из него carrier credentials и body.
Вместо origin можно использовать immutable snapshot статического сайта:
```toml
[web.vhosts.decoy]
mode = "static_directory"
directory = "/var/lib/telemt/public"
index = "index.html"
```
Статические файлы читаются при запуске и успешном reload конфигурации. Число элементов, размер одного файла и общий размер snapshot ограничены `[web.limits]`. Symlinks и пути с выходом из настроенного каталога запрещены. Не изменяйте каталог одновременно с построением snapshot в Telemt.
Все WEB-ключи и defaults перечислены в [справочнике конфигурации](../Config_params/CONFIG_PARAMS.ru.md#web).
## Терминация TLS на NGINX
```nginx
upstream telemt_web {
server 127.0.0.1:18080;
keepalive 64;
}
server {
listen 443 ssl;
http2 on;
server_name proxy.example.com;
access_log off;
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
client_max_body_size 2m;
location / {
proxy_pass http://telemt_web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 35s;
proxy_read_timeout 35s;
proxy_request_buffering off;
proxy_buffering off;
proxy_next_upstream off;
}
}
```
`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.
Для `https-lanes` обязателен публичный HTTP/2; используйте эквивалентную HTTP/2-директиву, поддерживаемую установленной версией NGINX. Приватный hop NGINX-to-Telemt намеренно остаётся HTTP/1.1. Upstream connection capacity должна выдерживать ожидаемое число одновременных lane polls; `keepalive` управляет idle pool и не является лимитом concurrency.
## Терминация TLS на HAProxy
```haproxy
frontend public_https
mode http
no log
bind :443 ssl crt /etc/haproxy/certs/proxy.example.com.pem alpn h2,http/1.1
acl telemt_web_host hdr(host) -i proxy.example.com proxy.example.com:443
use_backend telemt_web if telemt_web_host
backend telemt_web
mode http
option http-keep-alive
retries 0
timeout connect 5s
timeout server 35s
http-request set-header Host proxy.example.com
http-request del-header X-Forwarded-For
http-request set-header X-Forwarded-For %[src]
server telemt_web_1 127.0.0.1:18080 check
```
Во frontend или секции `defaults` также задайте `timeout client` выше long-poll deadline. Для `https-lanes` публичный ALPN HAProxy должен содержать `h2`. Не переписывайте path, raw query, body и carrier headers `Authorization`, `Content-Type`, `X-Up-Seq`, `X-Down-Cursor`, `X-Lane-ID`.
## Lifecycle и reload
| Конфигурация | Поведение runtime |
| --- | --- |
| Состав WEB-listeners, bind address и trust policy | Принадлежат процессу; перезапустите Telemt. |
| Любое значение `[web.limits]` | Process-owned контракт памяти и ресурсов; перезапустите Telemt. |
| `web.enabled`, `web.carrier`, timeouts, vhosts, profiles и decoys | Применяются config watcher или runtime generation reload. |
| Существующие HTTP connections и WEB sessions | Сохраняют carrier, лимиты и deadlines своего момента создания; новые bridge sessions получают активный carrier. Новые logical streams используют активное relay generation. |
| Завершение процесса | Использует последнее применённое значение `web.timeouts.shutdown_secs`. |
Каждый logical stream сохраняет client IP своей сессии и владеет уникальным в пределах процесса ненулевым synthetic source port до завершения relay. Это сохраняет один стабильный непересекающийся source/destination tuple для Direct и Middle-End KDF routing.
## Управление через API
Управление через API доступно, но намеренно ограничено. Отдельных endpoint `/v1/web` и WEB-specific runtime statistics endpoint сейчас нет.
| Операция | Поддержка API |
| --- | --- |
| Чтение или изменение `[web]`, vhosts, profiles, decoys, timeouts или limits | Нет. `GET /v1/config` не возвращает `[web]`; `PATCH /v1/config` отвечает `400 section_not_editable` на ключ `web`. |
| Сохранение `server.listeners` | Да, через `PATCH /v1/config`, но изменённый WEB-listener остаётся deferred до перезапуска процесса. |
| Применение WEB-конфигурации, изменённой вне API | Да, через `POST /v1/system/reload` с последующей проверкой статуса операции. |
| Управление `[access.users]` | Да, через `/v1/users`. Создание пользователя не создаёт WEB-профиль. |
| Отзыв отдельного пользователя | Да. `/v1/users/{username}/disable` немедленно обновляет admission и завершает активные сессии пользователя. |
Привяжите API к loopback, оставьте узким whitelist непосредственных peers, настройте точное значение authorization header и используйте `read_only = false` только там, где нужны мутации:
```toml
[server.api]
enabled = true
listen = "127.0.0.1:9091"
whitelist = ["127.0.0.0/8"]
auth_header = "Bearer replace-with-a-random-control-token"
read_only = false
```
API whitelist проверяет непосредственный TCP peer и не доверяет `X-Forwarded-For`. Изменения самой секции `[server.api]` требуют перезапуска процесса.
После атомарного изменения TOML-файла администратором или системой управления конфигурацией задайте в `TELEMT_API_AUTH` точное значение `auth_header` и отправьте наблюдаемый generation reload:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/system/reload \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{"mode":"drain","timeout_secs":30,"failure_policy":"rollback"}'
# Use data.reload_id from the response.
curl -sS http://127.0.0.1:9091/v1/system/reload/RELOAD_ID \
-H "Authorization: ${TELEMT_API_AUTH}"
```
Терминальный статус `succeeded` подтверждает активацию runtime. Изменённый `web.carrier` используют новые bridge sessions; существующие сессии не мигрируют. Если `deferred_process_fields` содержит `server.listeners` или `web.limits`, файл валиден и сохранён, но эти настройки всё ещё требуют перезапуска Telemt.
Операции с access users используют существующие endpoints, например:
```bash
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/disable \
-H "Authorization: ${TELEMT_API_AUTH}"
curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \
-H "Authorization: ${TELEMT_API_AUTH}" \
-H 'Content-Type: application/json' \
-d '{}'
```
После ротации секрета config watcher перестраивает WEB capabilities. Users API возвращает секрет, но не URL `tg://webproxy`; соберите ссылку из настроенного hostname и представления `plain` или `dd` соответствующего профиля. Перед удалением пользователя, на которого ссылается WEB-профиль, сначала удалите и примените этот профиль, чтобы итоговая конфигурация оставалась валидной.
Полный контракт запросов, revisions, ошибок и всех user endpoints приведён в [документации Control API](../Architecture/API/API.md).
## Инварианты развёртывания
- Никогда не публикуйте plain HTTP WEB-listener в недоверенной сети. Закрепите это host firewall rules, даже если listener использует loopback.
- Отключите логирование request target и authorization на TLS-терминаторе либо используйте проверенный формат с редактированием. Raw queries содержат bridge capabilities, а `Authorization` — bootstrap или session bearer credentials.
- Сохраняйте один стабильный публичный адрес на vhost. Если DNS возвращает несколько ingress addresses, каждый deployment должен использовать адрес своего внешнего пути.
- Bootstrap- и session-registries локальны для процесса. Для multi-process или multi-host upstream pool нужна affinity всего vhost: bridge GET, создание сессии, uplink, downlink и DELETE. Одному процессу Telemt дополнительная affinity не нужна.
- Decoy входит в anti-probing contract. До распространения ссылок проверьте через публичный TLS endpoint его обычный ответ 404 и response timing.
## Первичная проверка
1. Запустите пересобранный Telemt с WEB-конфигурацией и убедитесь, что приватный listener привязан.
2. Через публичный TLS endpoint проверьте, что `GET /`, неизвестный path и некорректный query `bridge` возвращают настроенный decoy site.
3. Убедитесь, что Telemt получает один канонический адрес `X-Forwarded-For` и `Host: proxy.example.com` либо `Host: proxy.example.com:443`.
4. Импортируйте напечатанную ссылку `tg://webproxy` в целевую сборку Telegram Desktop и установите соединение через прокси.
5. Для `https-lanes` подтвердите согласование HTTP/2 на публичном connection и проверьте как минимум два одновременных logical streams; приватный hop к Telemt остаётся HTTP/1.1.
6. Проверьте reconnect и как минимум один long poll длительнее 25 секунд, чтобы frontend timeouts не обрывали carrier.
7. Проверяйте лимиты пользователя и logical MTProxy connections по logical-stream counters, а не по числу HTTP connections.
## Диагностика
| Симптом | Что проверить |
| --- | --- |
| WEB-конфигурация валидна на диске, но поведение listener’а не изменилось | Проверьте `deferred_process_fields`; listener и `[web.limits]` требуют перезапуска. |
| Carrier-запросы попадают в decoy | Проверьте точный vhost, secret mode ссылки, CIDR непосредственного proxy и единственное каноническое значение `X-Forwarded-For`. |
| Long polls разрываются через фиксированный интервал | Поднимите client, server, send и read timeouts NGINX/HAProxy выше `web.timeouts.long_poll_secs`. |
| `https-lanes` работает, но streams всё ещё блокируют друг друга | Проверьте согласование публичного HTTP/2, сохранение `X-Lane-ID` и достаточное число upstream connections TLS-терминатора для параллельных приватных HTTP/1.1 polls. |
| Telegram Desktop отклоняет ссылку | Не указывайте порт, используйте валидный FQDN, внешний порт 443 и только `plain` или `dd`. |
| Один узел работает, но load-balanced pool нестабилен | Настройте affinity всего vhost: WEB credential registries локальны для процесса. |
+6
View File
@@ -337,9 +337,15 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
let process_limits = cfg.web.limits.clone();
cfg.web = new.web.clone();
cfg.web.limits = process_limits;
if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None;
}
if cfg.rebuild_runtime_web().is_err() {
cfg.web = old.web.clone();
}
cfg
}
+3
View File
@@ -123,6 +123,7 @@ fn listener_synlimit_fields_are_process_owned() {
let mut old = sample_config();
old.server.listeners.push(ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit: SynLimitMode::Iptables,
@@ -138,6 +139,8 @@ fn listener_synlimit_fields_are_process_owned() {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
let mut new = old.clone();
new.server.port = 8443;
+12
View File
@@ -22,6 +22,8 @@ mod includes;
mod strict_keys;
// Precomputed user authentication data for handshake hot paths.
mod runtime_auth;
// Validated immutable WEB configuration and static-site snapshots.
mod runtime_web;
// Post-deserialization validation helpers.
mod decode;
mod effective;
@@ -30,6 +32,7 @@ mod validate_core;
mod validate_me;
mod validate_runtime;
mod validate_server;
mod validate_web;
mod validation;
use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes};
@@ -96,6 +99,10 @@ pub struct ProxyConfig {
#[serde(default)]
pub server: ServerConfig,
/// WEB carrier ingress and public-site fallback configuration.
#[serde(default)]
pub web: WebConfig,
/// Timeout values used by client, fallback, and upstream operations.
#[serde(default)]
pub timeouts: TimeoutsConfig,
@@ -204,6 +211,11 @@ impl ProxyConfig {
Ok(())
}
/// Rebuilds validated WEB capabilities and immutable decoy snapshots.
pub(crate) fn rebuild_runtime_web(&mut self) -> Result<()> {
runtime_web::rebuild(self)
}
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
self.runtime_user_auth.as_deref()
}
+7
View File
@@ -120,6 +120,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
config.server.listeners.push(ListenerConfig {
ip: ipv4,
transport: ListenerTransport::Mtproxy,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
@@ -135,6 +136,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
}
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
@@ -142,6 +145,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
{
config.server.listeners.push(ListenerConfig {
ip: ipv6,
transport: ListenerTransport::Mtproxy,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
@@ -157,6 +161,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
}
}
@@ -211,5 +217,6 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
validate_logging_config(&config.logging)?;
validate_upstreams(config)?;
config.rebuild_runtime_user_auth()?;
config.rebuild_runtime_web()?;
Ok(())
}
+1
View File
@@ -7,6 +7,7 @@ pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig
validate_runtime::validate(&mut config)?;
validate_me::validate(&mut config)?;
validate_server::validate(&mut config)?;
validate_web::validate(&mut config)?;
effective::apply(&mut config)?;
Ok(LoadedConfig {
config,
+415
View File
@@ -0,0 +1,415 @@
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use bytes::Bytes;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use super::*;
const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
const MAX_WEB_STATIC_DEPTH: usize = 64;
/// Builds the immutable WEB routing and decoy snapshot for one generation.
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let auth = config.runtime_user_auth().ok_or_else(|| {
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
})?;
let mut runtime_vhosts = BTreeMap::new();
let mut runtime_profiles = Vec::new();
let mut static_files = 0usize;
let mut static_bytes = 0usize;
for vhost in &config.web.vhosts {
let decoy = build_decoy(
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?;
let mut profiles = Vec::with_capacity(vhost.profiles.len());
let mut capabilities = HashSet::with_capacity(vhost.profiles.len());
for profile in &vhost.profiles {
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
ProxyError::Config(format!(
"WEB profile references unknown access user `{}`",
profile.user
))
})?;
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let capability = derive_web_capability(
&client_secret[..client_secret_len],
vhost.host.as_bytes(),
)?;
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{}` contains profiles with the same client capability",
vhost.host
)));
}
let runtime_profile = Arc::new(WebRuntimeProfile {
host: vhost.host.clone(),
public_addr: vhost.public_addr,
user: profile.user.clone(),
secret_mode: profile.secret_mode,
carrier: config.web.carrier,
capability,
max_sessions: profile
.max_sessions
.unwrap_or(config.web.limits.max_sessions_global),
max_streams: profile
.max_streams
.unwrap_or(config.web.limits.max_streams_global),
max_streams_per_session: profile
.max_streams_per_session
.unwrap_or(config.web.limits.max_streams_per_session),
});
profiles.push(Arc::clone(&runtime_profile));
runtime_profiles.push(runtime_profile);
}
runtime_vhosts.insert(
vhost.host.clone(),
Arc::new(WebRuntimeVhost {
host: vhost.host.clone(),
decoy,
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
}),
);
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts: runtime_vhosts,
profiles: runtime_profiles,
}));
Ok(())
}
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).map_err(|_| {
ProxyError::Config("WEB capability secret must not be empty".to_string())
})?;
mac.update(WEB_CAPABILITY_CONTEXT);
mac.update(host);
Ok(mac.finalize().into_bytes().into())
}
fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
let mut client_secret = [0u8; 17];
match mode {
WebSecretMode::Plain => {
client_secret[..16].copy_from_slice(&secret);
(client_secret, 16)
}
WebSecretMode::Dd => {
client_secret[0] = 0xdd;
client_secret[1..].copy_from_slice(&secret);
(client_secret, 17)
}
}
}
fn build_decoy(
vhost: &WebVhostConfig,
limits: &WebLimitsConfig,
static_files: &mut usize,
static_bytes: &mut usize,
) -> Result<WebRuntimeDecoy> {
match &vhost.decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"WEB decoy upstream for `{}` is invalid: {error}",
vhost.host
))
})?;
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip),
_ => {
return Err(ProxyError::Config(
"WEB decoy host must be an IP literal".to_string(),
));
}
};
let host = ip.to_string();
let port = parsed.port_or_known_default().ok_or_else(|| {
ProxyError::Config("WEB decoy port cannot be resolved".to_string())
})?;
let authority = match (ip, parsed.port()) {
(std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"),
(std::net::IpAddr::V6(_), None) => format!("[{host}]"),
(std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"),
(std::net::IpAddr::V4(_), None) => host.clone(),
};
Ok(WebRuntimeDecoy::HttpUpstream {
addr: SocketAddr::new(ip, port),
authority,
})
}
WebDecoyConfig::StaticDirectory { directory, index } => {
let site = load_static_site(
directory,
index,
limits,
static_files,
static_bytes,
)?;
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
}
}
}
fn load_static_site(
root: &Path,
index: &str,
limits: &WebLimitsConfig,
total_files: &mut usize,
total_bytes: &mut usize,
) -> Result<WebStaticSite> {
let root_metadata = fs::symlink_metadata(root).map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static directory `{}`: {error}",
root.display()
))
})?;
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` must be a real directory, not a symlink",
root.display()
)));
}
let canonical_root = fs::canonicalize(root).map_err(|error| {
ProxyError::Config(format!(
"failed to canonicalize WEB static directory `{}`: {error}",
root.display()
))
})?;
let mut assets = BTreeMap::new();
load_static_directory(
&canonical_root,
&canonical_root,
&mut assets,
total_files,
total_bytes,
limits,
0,
)?;
if !assets.contains_key(&format!("/{index}")) {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` does not contain index `{index}`",
root.display()
)));
}
Ok(WebStaticSite {
assets,
index: index.to_string(),
})
}
fn load_static_directory(
root: &Path,
directory: &Path,
assets: &mut BTreeMap<String, WebStaticAsset>,
total_files: &mut usize,
total_bytes: &mut usize,
limits: &WebLimitsConfig,
depth: usize,
) -> Result<()> {
let entries = fs::read_dir(directory).map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static directory `{}`: {error}",
directory.display()
))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
ProxyError::Config(format!("failed to read WEB static entry: {error}"))
})?;
if *total_files >= limits.max_static_files {
return Err(ProxyError::Config(
"WEB static entries exceed process-wide web.limits.max_static_files"
.to_string(),
));
}
*total_files += 1;
let path = entry.path();
let file_type = entry.file_type().map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static entry `{}`: {error}",
path.display()
))
})?;
if file_type.is_symlink() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` must not be a symlink",
path.display()
)));
}
if file_type.is_dir() {
if depth >= MAX_WEB_STATIC_DEPTH {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` exceeds the maximum nesting depth",
path.display()
)));
}
load_static_directory(
root,
&path,
assets,
total_files,
total_bytes,
limits,
depth + 1,
)?;
continue;
}
if !file_type.is_file() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` must be a regular file",
path.display()
)));
}
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
let file = options.open(&path).map_err(|error| {
ProxyError::Config(format!(
"failed to open WEB static file `{}`: {error}",
path.display()
))
})?;
let metadata = file.metadata().map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static file `{}`: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` changed before it was opened",
path.display()
)));
}
let file_len = usize::try_from(metadata.len()).map_err(|_| {
ProxyError::Config(format!("WEB static file `{}` is too large", path.display()))
})?;
if file_len > limits.max_static_file_bytes {
return Err(ProxyError::Config(format!(
"WEB static file `{}` exceeds web.limits.max_static_file_bytes",
path.display()
)));
}
*total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| {
ProxyError::Config("WEB static snapshot byte count overflowed usize".to_string())
})?;
if *total_bytes > limits.max_static_bytes {
return Err(ProxyError::Config(
"WEB static snapshots exceed process-wide web.limits.max_static_bytes"
.to_string(),
));
}
let relative = path.strip_prefix(root).map_err(|_| {
ProxyError::Config("WEB static path escaped its configured root".to_string())
})?;
let route = static_route(relative)?;
let mut body = Vec::with_capacity(file_len);
file.take(limits.max_static_file_bytes as u64 + 1)
.read_to_end(&mut body)
.map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static file `{}`: {error}",
path.display()
))
})?;
if body.len() != file_len {
return Err(ProxyError::Config(format!(
"WEB static file `{}` changed while its snapshot was built",
path.display()
)));
}
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
assets.insert(
route,
WebStaticAsset {
body: Bytes::from(body),
content_type: static_content_type(&path),
etag,
},
);
}
Ok(())
}
fn static_route(relative: &Path) -> Result<String> {
let mut route = String::new();
for component in relative.components() {
let std::path::Component::Normal(component) = component else {
return Err(ProxyError::Config(
"WEB static path contains an unsafe component".to_string(),
));
};
let component = component.to_str().ok_or_else(|| {
ProxyError::Config("WEB static file names must be valid UTF-8".to_string())
})?;
route.push('/');
route.push_str(component);
}
Ok(route)
}
fn static_content_type(path: &Path) -> &'static str {
match path.extension().and_then(|extension| extension.to_str()) {
Some("html") | Some("htm") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("json") => "application/json",
Some("txt") => "text/plain; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("wasm") => "application/wasm",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use super::*;
#[test]
fn capability_matches_reference_vectors() {
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap();
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain),
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"
);
let mut dd_secret = vec![0xdd];
dd_secret.extend_from_slice(&secret);
let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap();
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd),
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"
);
}
}
+71 -293
View File
@@ -7,6 +7,7 @@ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"logging",
"network",
"server",
"web",
"timeouts",
"censorship",
"access",
@@ -238,6 +239,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip",
"transport",
"port",
"client_mss",
"synlimit",
@@ -253,6 +255,70 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"announce_ip",
"proxy_protocol",
"reuse_allow",
"web_client_ip_source",
"web_trusted_proxy_cidrs",
];
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "carrier", "limits", "timeouts", "vhosts"];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_header_bytes",
"max_body_bytes",
"max_frame_payload_bytes",
"carrier_batch_bytes",
"max_frames_per_body",
"max_http_connections",
"max_http_handlers",
"max_body_readers",
"max_body_bytes_global",
"max_sessions_global",
"max_sessions_per_ip",
"max_streams_per_session",
"max_streams_global",
"max_stream_handshakes",
"max_tombstones_per_session",
"pending_bytes_per_session",
"pending_bytes_global",
"pending_items_per_session",
"pending_items_global",
"control_bytes_per_session",
"control_bytes_global",
"max_bootstraps_global",
"max_bootstraps_per_ip",
"max_vhosts",
"max_profiles",
"max_static_files",
"max_static_file_bytes",
"max_static_bytes",
"memory_envelope_bytes",
"new_bootstraps_per_minute",
"new_bootstraps_burst",
"new_sessions_per_minute",
"new_sessions_burst",
"new_streams_per_minute",
"new_streams_burst",
];
const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"header_secs",
"body_secs",
"stream_handshake_secs",
"long_poll_secs",
"bootstrap_lifetime_secs",
"reconnect_grace_secs",
"http_idle_secs",
"shutdown_secs",
"decoy_header_secs",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "public_addr", "decoy", "profiles"];
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
"secret_mode",
"max_sessions",
"max_streams",
"max_streams_per_session",
];
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
@@ -366,300 +432,12 @@ const LOGGING_CONFIG_KEYS: &[&str] = &[
"max_age_secs",
];
#[derive(Debug)]
struct UnknownConfigKey {
path: String,
suggestion: Option<String>,
}
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_table()
}
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
table_at(parsed_toml, &["general"])
.and_then(|table| table.get("config_strict"))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
let mut keys = Vec::new();
for group in [
TOP_LEVEL_CONFIG_KEYS,
GENERAL_CONFIG_KEYS,
NETWORK_CONFIG_KEYS,
SERVER_CONFIG_KEYS,
API_CONFIG_KEYS,
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
TIMEOUTS_CONFIG_KEYS,
CENSORSHIP_CONFIG_KEYS,
TLS_FETCH_CONFIG_KEYS,
ACCESS_CONFIG_KEYS,
RATE_LIMIT_BPS_CONFIG_KEYS,
UPSTREAM_CONFIG_KEYS,
PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] {
keys.extend_from_slice(group);
}
keys
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
let normalized = key.to_ascii_lowercase();
let mut best: Option<(&str, usize)> = None;
for known in known_keys {
let distance = levenshtein_distance(&normalized, known);
let is_better = match best {
Some((_, best_distance)) => distance < best_distance,
None => true,
};
if distance <= 4 && is_better {
best = Some((known, distance));
}
}
best.map(|(known, _)| known.to_string())
}
fn push_unknown_keys(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &str,
table: &toml::Table,
allowed: &[&str],
) {
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
let full_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
unknown.push(UnknownConfigKey {
path: full_path,
suggestion: unknown_key_suggestion(key, known_for_suggestion),
});
}
}
}
fn check_known_table(
parsed_toml: &toml::Value,
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &[&str],
allowed: &[&str],
) {
if let Some(table) = table_at(parsed_toml, path) {
push_unknown_keys(
unknown,
known_for_suggestion,
&path.join("."),
table,
allowed,
);
}
}
fn check_nested_table_value(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: String,
value: &toml::Value,
allowed: &[&str],
) {
if let Some(table) = value.as_table() {
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
}
}
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
let known_for_suggestion = known_config_keys_for_suggestion();
let mut unknown = Vec::new();
if let Some(root) = parsed_toml.as_table() {
push_unknown_keys(
&mut unknown,
&known_for_suggestion,
"",
root,
TOP_LEVEL_CONFIG_KEYS,
);
}
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general"],
GENERAL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "modes"],
PROXY_MODES_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "telemetry"],
TELEMETRY_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["network"],
NETWORK_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server"],
SERVER_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "admin_api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "conntrack_control"],
CONNTRACK_CONTROL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["timeouts"],
TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship"],
CENSORSHIP_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship", "tls_fetch"],
TLS_FETCH_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["access"],
ACCESS_CONFIG_KEYS,
);
if let Some(listeners) = table_at(parsed_toml, &["server"])
.and_then(|table| table.get("listeners"))
.and_then(toml::Value::as_array)
{
for (idx, listener) in listeners.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("server.listeners[{idx}]"),
listener,
LISTENER_CONFIG_KEYS,
);
}
}
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
for (idx, upstream) in upstreams.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("upstreams[{idx}]"),
upstream,
UPSTREAM_CONFIG_KEYS,
);
}
}
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
if let Some(table) = table_at(parsed_toml, &["access"])
.and_then(|access| access.get(access_map))
.and_then(toml::Value::as_table)
{
for (entry_name, value) in table {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("access.{access_map}.{entry_name}"),
value,
RATE_LIMIT_BPS_CONFIG_KEYS,
);
}
}
}
unknown
}
// Recursive table traversal and key suggestion logic.
mod check;
/// Rejects or reports unknown configuration keys according to strict mode.
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
let unknown = collect_unknown_config_keys(parsed_toml);
let unknown = check::collect_unknown_config_keys(parsed_toml);
if unknown.is_empty() {
return Ok(());
}
@@ -676,7 +454,7 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
}
}
if is_strict_config(parsed_toml) {
if check::is_strict_config(parsed_toml) {
let mut paths = Vec::with_capacity(unknown.len());
for item in unknown {
if let Some(suggestion) = item.suggestion {
+362
View File
@@ -0,0 +1,362 @@
use super::*;
#[derive(Debug)]
/// One rejected configuration path and its optional nearest known key.
pub(super) struct UnknownConfigKey {
/// Fully qualified configuration path.
pub(super) path: String,
/// Nearest known key when edit distance is sufficiently small.
pub(super) suggestion: Option<String>,
}
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_table()
}
/// Reads strict-key enforcement without deserializing the full configuration.
pub(super) fn is_strict_config(parsed_toml: &toml::Value) -> bool {
table_at(parsed_toml, &["general"])
.and_then(|table| table.get("config_strict"))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
let mut keys = Vec::new();
for group in [
TOP_LEVEL_CONFIG_KEYS,
GENERAL_CONFIG_KEYS,
NETWORK_CONFIG_KEYS,
SERVER_CONFIG_KEYS,
API_CONFIG_KEYS,
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
WEB_CONFIG_KEYS,
WEB_LIMITS_CONFIG_KEYS,
WEB_TIMEOUTS_CONFIG_KEYS,
WEB_VHOST_CONFIG_KEYS,
WEB_DECOY_CONFIG_KEYS,
WEB_PROFILE_CONFIG_KEYS,
TIMEOUTS_CONFIG_KEYS,
CENSORSHIP_CONFIG_KEYS,
TLS_FETCH_CONFIG_KEYS,
ACCESS_CONFIG_KEYS,
RATE_LIMIT_BPS_CONFIG_KEYS,
UPSTREAM_CONFIG_KEYS,
PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] {
keys.extend_from_slice(group);
}
keys
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
let normalized = key.to_ascii_lowercase();
let mut best: Option<(&str, usize)> = None;
for known in known_keys {
let distance = levenshtein_distance(&normalized, known);
let is_better = match best {
Some((_, best_distance)) => distance < best_distance,
None => true,
};
if distance <= 4 && is_better {
best = Some((known, distance));
}
}
best.map(|(known, _)| known.to_string())
}
fn push_unknown_keys(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &str,
table: &toml::Table,
allowed: &[&str],
) {
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
let full_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
unknown.push(UnknownConfigKey {
path: full_path,
suggestion: unknown_key_suggestion(key, known_for_suggestion),
});
}
}
}
fn check_known_table(
parsed_toml: &toml::Value,
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &[&str],
allowed: &[&str],
) {
if let Some(table) = table_at(parsed_toml, path) {
push_unknown_keys(
unknown,
known_for_suggestion,
&path.join("."),
table,
allowed,
);
}
}
fn check_nested_table_value(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: String,
value: &toml::Value,
allowed: &[&str],
) {
if let Some(table) = value.as_table() {
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
}
}
/// Collects unknown keys across every supported nested configuration table.
pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
let known_for_suggestion = known_config_keys_for_suggestion();
let mut unknown = Vec::new();
if let Some(root) = parsed_toml.as_table() {
push_unknown_keys(
&mut unknown,
&known_for_suggestion,
"",
root,
TOP_LEVEL_CONFIG_KEYS,
);
}
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general"],
GENERAL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "modes"],
PROXY_MODES_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "telemetry"],
TELEMETRY_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["network"],
NETWORK_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server"],
SERVER_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "admin_api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "conntrack_control"],
CONNTRACK_CONTROL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web"],
WEB_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "limits"],
WEB_LIMITS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "timeouts"],
WEB_TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["timeouts"],
TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship"],
CENSORSHIP_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship", "tls_fetch"],
TLS_FETCH_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["access"],
ACCESS_CONFIG_KEYS,
);
if let Some(listeners) = table_at(parsed_toml, &["server"])
.and_then(|table| table.get("listeners"))
.and_then(toml::Value::as_array)
{
for (idx, listener) in listeners.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("server.listeners[{idx}]"),
listener,
LISTENER_CONFIG_KEYS,
);
}
}
if let Some(vhosts) = table_at(parsed_toml, &["web"])
.and_then(|table| table.get("vhosts"))
.and_then(toml::Value::as_array)
{
for (vhost_idx, vhost) in vhosts.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}]"),
vhost,
WEB_VHOST_CONFIG_KEYS,
);
if let Some(vhost) = vhost.as_table() {
if let Some(decoy) = vhost.get("decoy") {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}].decoy"),
decoy,
WEB_DECOY_CONFIG_KEYS,
);
}
if let Some(profiles) = vhost.get("profiles").and_then(toml::Value::as_array) {
for (profile_idx, profile) in profiles.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}].profiles[{profile_idx}]"),
profile,
WEB_PROFILE_CONFIG_KEYS,
);
}
}
}
}
}
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
for (idx, upstream) in upstreams.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("upstreams[{idx}]"),
upstream,
UPSTREAM_CONFIG_KEYS,
);
}
}
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
if let Some(table) = table_at(parsed_toml, &["access"])
.and_then(|access| access.get(access_map))
.and_then(toml::Value::as_table)
{
for (entry_name, value) in table {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("access.{access_map}.{entry_name}"),
value,
RATE_LIMIT_BPS_CONFIG_KEYS,
);
}
}
}
unknown
}
+550
View File
@@ -0,0 +1,550 @@
use std::collections::HashSet;
use super::*;
const WEB_FRAME_HEADER_BYTES: usize = 8;
const WEB_QUEUE_ITEM_COST: usize = 256;
const WEB_CONTROL_EXTRA_ITEMS: usize = 16;
const WEB_CONTROL_ITEMS_PER_STREAM: usize = 3;
const WEB_INITIAL_STREAM_WINDOW: usize = 4 * 1024 * 1024;
const MAX_WEB_HEADER_BYTES: usize = 64 * 1024;
const MAX_WEB_BODY_BYTES: usize = 16 * 1024 * 1024;
const MAX_WEB_FRAME_BYTES: usize = 1024 * 1024;
const MAX_WEB_FRAMES_PER_BODY: usize = 4096;
const MAX_WEB_TOMBSTONES_PER_SESSION: usize = 4096;
const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024;
/// Validates WEB policy and resource bounds before building runtime state.
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
let web_listener_count = config
.server
.listeners
.iter()
.filter(|listener| listener.transport == ListenerTransport::Web)
.count();
let eligible_web_listener_count = config
.server
.listeners
.iter()
.filter(|listener| listener.transport == ListenerTransport::Web)
.filter(|listener| {
(listener.ip.is_ipv4() && config.network.ipv4)
|| (listener.ip.is_ipv6() && config.network.ipv6 != Some(false))
})
.count();
for (idx, listener) in config.server.listeners.iter().enumerate() {
match listener.transport {
ListenerTransport::Mtproxy => {
if !listener.web_trusted_proxy_cidrs.is_empty() {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs is only valid for transport=web"
)));
}
}
ListenerTransport::Web => validate_web_listener(config, idx, listener)?,
}
}
if config.web.enabled && eligible_web_listener_count == 0 {
return Err(ProxyError::Config(
"web.enabled requires at least one network-eligible server.listeners entry with transport=web"
.to_string(),
));
}
if web_listener_count > 0 && config.web.vhosts.is_empty() {
return Err(ProxyError::Config(
"WEB listeners require at least one [[web.vhosts]] entry".to_string(),
));
}
validate_limits(&config.web.limits)?;
if config.web.carrier == WebCarrier::HttpsLanes
&& config.web.limits.max_http_handlers < 2
{
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2");
}
validate_timeouts(&config.web.timeouts)?;
validate_vhosts(config)?;
Ok(())
}
fn validate_web_listener(
config: &ProxyConfig,
idx: usize,
listener: &ListenerConfig,
) -> Result<()> {
if listener.web_trusted_proxy_cidrs.is_empty() {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs must be non-empty for transport=web"
)));
}
if listener
.web_trusted_proxy_cidrs
.iter()
.any(|network| network.prefix() == 0)
{
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs must not contain a /0 network"
)));
}
let proxy_protocol = listener
.proxy_protocol
.unwrap_or(config.server.proxy_protocol);
if proxy_protocol {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].proxy_protocol must be false for transport=web; WEB identity is accepted only from the configured L7 header"
)));
}
if listener.reuse_allow {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].reuse_allow is not supported for transport=web without external session affinity"
)));
}
if listener.client_mss.is_some()
|| listener.synlimit != SynLimitMode::Off
|| listener.announce.is_some()
|| listener.announce_ip.is_some()
{
return Err(ProxyError::Config(format!(
"server.listeners[{idx}] WEB transport does not accept client_mss, synlimit, announce, or announce_ip"
)));
}
Ok(())
}
fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
return config_error("web.limits.max_header_bytes must be within [8192, 65536]");
}
if !(WEB_FRAME_HEADER_BYTES..=MAX_WEB_BODY_BYTES).contains(&limits.max_body_bytes) {
return config_error("web.limits.max_body_bytes must be within [8, 16777216]");
}
if !(1..=MAX_WEB_FRAME_BYTES).contains(&limits.max_frame_payload_bytes) {
return config_error("web.limits.max_frame_payload_bytes must be within [1, 1048576]");
}
if !(1..=MAX_WEB_FRAMES_PER_BODY).contains(&limits.max_frames_per_body) {
return config_error("web.limits.max_frames_per_body must be within [1, 4096]");
}
if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) {
return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]");
}
if limits.carrier_batch_bytes > limits.max_body_bytes
|| limits.carrier_batch_bytes
< limits
.max_frame_payload_bytes
.saturating_add(WEB_FRAME_HEADER_BYTES)
{
return config_error(
"web.limits.carrier_batch_bytes must fit max_body_bytes and one maximum frame",
);
}
if limits.max_frame_payload_bytes > WEB_INITIAL_STREAM_WINDOW {
return config_error(
"web.limits.max_frame_payload_bytes must not exceed the initial stream window",
);
}
let positive = [
("max_http_connections", limits.max_http_connections),
("max_http_handlers", limits.max_http_handlers),
("max_body_readers", limits.max_body_readers),
("max_body_bytes_global", limits.max_body_bytes_global),
("max_sessions_global", limits.max_sessions_global),
("max_sessions_per_ip", limits.max_sessions_per_ip),
("max_streams_per_session", limits.max_streams_per_session),
("max_streams_global", limits.max_streams_global),
("max_stream_handshakes", limits.max_stream_handshakes),
("pending_bytes_per_session", limits.pending_bytes_per_session),
("pending_bytes_global", limits.pending_bytes_global),
("pending_items_per_session", limits.pending_items_per_session),
("pending_items_global", limits.pending_items_global),
("control_bytes_per_session", limits.control_bytes_per_session),
("control_bytes_global", limits.control_bytes_global),
("max_bootstraps_global", limits.max_bootstraps_global),
("max_bootstraps_per_ip", limits.max_bootstraps_per_ip),
("max_vhosts", limits.max_vhosts),
("max_profiles", limits.max_profiles),
("max_static_files", limits.max_static_files),
("max_static_file_bytes", limits.max_static_file_bytes),
("max_static_bytes", limits.max_static_bytes),
("memory_envelope_bytes", limits.memory_envelope_bytes),
];
if let Some((field, _)) = positive.into_iter().find(|(_, value)| *value == 0) {
return config_error(&format!("web.limits.{field} must be > 0"));
}
for (field, value) in [
("max_http_connections", limits.max_http_connections),
("max_http_handlers", limits.max_http_handlers),
("max_body_readers", limits.max_body_readers),
("max_body_bytes_global", limits.max_body_bytes_global),
("max_stream_handshakes", limits.max_stream_handshakes),
] {
if value > tokio::sync::Semaphore::MAX_PERMITS {
return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity"));
}
}
let rates = [
("new_bootstraps_per_minute", limits.new_bootstraps_per_minute),
("new_bootstraps_burst", limits.new_bootstraps_burst),
("new_sessions_per_minute", limits.new_sessions_per_minute),
("new_sessions_burst", limits.new_sessions_burst),
("new_streams_per_minute", limits.new_streams_per_minute),
("new_streams_burst", limits.new_streams_burst),
];
if let Some((field, _)) = rates.into_iter().find(|(_, value)| *value == 0) {
return config_error(&format!("web.limits.{field} must be > 0"));
}
if limits.max_streams_per_session > u16::MAX as usize {
return config_error("web.limits.max_streams_per_session must fit synthetic source ports");
}
if limits.max_sessions_per_ip > limits.max_sessions_global
|| limits.max_streams_per_session > limits.max_streams_global
|| limits.max_stream_handshakes > limits.max_streams_global
|| limits.max_bootstraps_per_ip > limits.max_bootstraps_global
|| limits.max_http_handlers > limits.max_http_connections
|| limits.max_body_readers > limits.max_http_handlers
|| limits.pending_bytes_per_session > limits.pending_bytes_global
|| limits.pending_items_per_session > limits.pending_items_global
|| limits.control_bytes_per_session > limits.control_bytes_global
|| limits.control_bytes_per_session > limits.pending_bytes_per_session
|| limits.control_bytes_global > limits.pending_bytes_global
|| limits.max_static_file_bytes > limits.max_static_bytes
{
return config_error("web.limits per-owner ceilings must not exceed global ceilings");
}
let control_items_per_session = WEB_CONTROL_EXTRA_ITEMS
.checked_add(
limits
.max_streams_per_session
.checked_mul(WEB_CONTROL_ITEMS_PER_STREAM)
.ok_or_else(|| {
ProxyError::Config(
"web.limits control item reservation overflowed usize".to_string(),
)
})?,
)
.ok_or_else(|| {
ProxyError::Config("web.limits control item reservation overflowed usize".to_string())
})?;
let control_items_global = control_items_per_session
.checked_mul(limits.max_sessions_global)
.ok_or_else(|| {
ProxyError::Config("web.limits global control reservation overflowed usize".to_string())
})?;
let control_frame_cost = WEB_FRAME_HEADER_BYTES + 4 + WEB_QUEUE_ITEM_COST;
let required_control_bytes_per_session = control_items_per_session
.checked_mul(control_frame_cost)
.ok_or_else(|| {
ProxyError::Config("web.limits control byte reservation overflowed usize".to_string())
})?;
let required_control_bytes_global = control_items_global
.checked_mul(control_frame_cost)
.ok_or_else(|| {
ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string())
})?;
if control_items_per_session >= limits.pending_items_per_session
|| control_items_global >= limits.pending_items_global
|| required_control_bytes_per_session > limits.control_bytes_per_session
|| required_control_bytes_global > limits.control_bytes_global
{
return config_error(
"web.limits control reserves must cover bounded control frames and leave data capacity",
);
}
let uplink_bytes = limits
.max_frames_per_body
.checked_mul(WEB_QUEUE_ITEM_COST)
.and_then(|value| value.checked_add(limits.max_body_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits uplink reservation overflowed usize".to_string())
})?;
let minimum_downlink_frame_bytes = WEB_FRAME_HEADER_BYTES + 1 + WEB_QUEUE_ITEM_COST;
let session_required_bytes = limits
.control_bytes_per_session
.checked_add(uplink_bytes)
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits session reservation overflowed usize".to_string())
})?;
let session_required_items = control_items_per_session
.checked_add(limits.max_frames_per_body)
.and_then(|value| value.checked_add(1))
.ok_or_else(|| {
ProxyError::Config("web.limits session item reservation overflowed usize".to_string())
})?;
let global_required_bytes = limits
.control_bytes_global
.checked_add(uplink_bytes)
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits global reservation overflowed usize".to_string())
})?;
let global_required_items = control_items_global
.checked_add(limits.max_frames_per_body)
.and_then(|value| value.checked_add(1))
.ok_or_else(|| {
ProxyError::Config("web.limits global item reservation overflowed usize".to_string())
})?;
if session_required_bytes > limits.pending_bytes_per_session
|| session_required_items > limits.pending_items_per_session
|| global_required_bytes > limits.pending_bytes_global
|| global_required_items > limits.pending_items_global
{
return config_error(
"web.limits pending ceilings must preserve one uplink batch and downlink progress",
);
}
let body_reservation = limits
.max_body_readers
.checked_mul(limits.max_body_bytes)
.ok_or_else(|| {
ProxyError::Config("web.limits body reader reservation overflowed usize".to_string())
})?;
if body_reservation > limits.max_body_bytes_global
|| limits.max_body_bytes_global > u32::MAX as usize
{
return config_error(
"web.limits max_body_readers * max_body_bytes must fit max_body_bytes_global and u32",
);
}
let http_header_reservation = limits
.max_http_connections
.checked_mul(limits.max_header_bytes)
.ok_or_else(|| {
ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string())
})?;
let reserved = limits
.pending_bytes_global
.checked_add(limits.max_body_bytes_global)
.and_then(|value| value.checked_add(limits.max_static_bytes))
.and_then(|value| value.checked_add(http_header_reservation))
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
if reserved > limits.memory_envelope_bytes
|| limits.memory_envelope_bytes > MAX_WEB_MEMORY_ENVELOPE_BYTES
{
return config_error(
"web.limits memory reservations must fit memory_envelope_bytes within 4 GiB",
);
}
Ok(())
}
fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> {
let values = [
("header_secs", timeouts.header_secs),
("body_secs", timeouts.body_secs),
("stream_handshake_secs", timeouts.stream_handshake_secs),
("long_poll_secs", timeouts.long_poll_secs),
("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs),
("reconnect_grace_secs", timeouts.reconnect_grace_secs),
("http_idle_secs", timeouts.http_idle_secs),
("shutdown_secs", timeouts.shutdown_secs),
("decoy_header_secs", timeouts.decoy_header_secs),
];
if let Some((field, _)) = values
.into_iter()
.find(|(_, value)| !(1..=3600).contains(value))
{
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
}
let request_deadline = timeouts
.header_secs
.max(timeouts.body_secs)
.max(timeouts.long_poll_secs)
.max(timeouts.decoy_header_secs);
if request_deadline >= timeouts.http_idle_secs {
return config_error("web.timeouts request deadlines must be lower than http_idle_secs");
}
Ok(())
}
fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits;
if config.web.vhosts.len() > limits.max_vhosts {
return config_error("web.vhosts exceeds web.limits.max_vhosts");
}
let mut hosts = HashSet::with_capacity(config.web.vhosts.len());
let mut profile_count = 0usize;
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
vhost.host = normalize_web_host(
&vhost.host,
&format!("web.vhosts[{vhost_idx}].host"),
)?;
if !hosts.insert(vhost.host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
}
if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443"
));
}
if config.web.enabled && vhost.profiles.is_empty() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles must be non-empty when web.enabled=true"
));
}
validate_decoy(vhost_idx, &vhost.decoy)?;
let mut profiles = HashSet::with_capacity(vhost.profiles.len());
for (profile_idx, profile) in vhost.profiles.iter().enumerate() {
if !config.access.users.contains_key(&profile.user) {
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles[{profile_idx}].user references unknown access user `{}`",
profile.user
));
}
if !profiles.insert((profile.user.as_str(), profile.secret_mode)) {
return config_error(&format!(
"duplicate WEB profile for user `{}` in vhost `{}`",
profile.user, vhost.host
));
}
let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global);
let max_streams_per_session = profile
.max_streams_per_session
.unwrap_or(limits.max_streams_per_session);
if profile.max_sessions == Some(0)
|| profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global)
|| profile.max_streams == Some(0)
|| profile
.max_streams
.is_some_and(|value| value > limits.max_streams_global)
|| profile.max_streams_per_session == Some(0)
|| profile
.max_streams_per_session
.is_some_and(|value| value > limits.max_streams_per_session)
|| max_streams_per_session > max_streams
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits"
));
}
profile_count = profile_count.checked_add(1).ok_or_else(|| {
ProxyError::Config("WEB profile count overflowed usize".to_string())
})?;
}
}
if profile_count > limits.max_profiles {
return config_error("WEB profiles exceed web.limits.max_profiles");
}
Ok(())
}
fn normalize_web_host(value: &str, field: &str) -> Result<String> {
let input = value.trim();
if input.is_empty()
|| input.ends_with('.')
|| input
.chars()
.any(|character| matches!(character, ':' | '/' | '?' | '#' | '@'))
{
return config_error(&format!(
"{field} must be a hostname without a port, path, credentials, or trailing dot"
));
}
let host = normalize_domain_to_ascii(input, field)?;
if host.len() > 253
|| !host.contains('.')
|| host.parse::<IpAddr>().is_ok()
|| web_host_last_label_is_numeric(&host)
{
return config_error(&format!(
"{field} must be a non-IP fully-qualified hostname accepted by Telegram Desktop"
));
}
for label in host.split('.') {
if label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return config_error(&format!(
"{field} contains a hostname label rejected by Telegram Desktop"
));
}
}
Ok(host)
}
fn web_host_last_label_is_numeric(host: &str) -> bool {
let label = host.rsplit('.').next().unwrap_or_default();
let digits = label
.strip_prefix("0x")
.or_else(|| label.strip_prefix("0X"));
if let Some(digits) = digits {
return digits.bytes().all(|byte| byte.is_ascii_hexdigit());
}
label.bytes().all(|byte| byte.is_ascii_digit())
}
fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
match decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
))
})?;
if parsed.scheme() != "http"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
|| parsed.port() == Some(0)
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
));
}
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
_ => {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal"
));
}
};
let private = match ip {
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
IpAddr::V6(ip) => {
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
}
};
if !private {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network"
));
}
}
WebDecoyConfig::StaticDirectory { directory, index } => {
if !directory.is_absolute() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.directory must be absolute"
));
}
if index.is_empty()
|| index.contains('\\')
|| std::path::Path::new(index).components().count() != 1
|| matches!(index.as_str(), "." | "..")
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.index must be one safe file name"
));
}
}
}
Ok(())
}
fn config_error<T>(message: &str) -> Result<T> {
Err(ProxyError::Config(message.to_string()))
}
#[cfg(test)]
mod tests;
+26
View File
@@ -0,0 +1,26 @@
use super::*;
#[test]
fn web_host_normalization_matches_client_vectors() {
assert_eq!(
normalize_web_host(" Proxy.Example.COM ", "host").unwrap(),
"proxy.example.com"
);
assert_eq!(
normalize_web_host("bücher.example", "host").unwrap(),
"xn--bcher-kva.example"
);
for invalid in [
"localhost",
"127.0.0.1",
"127.1",
"0x7f.1",
"0177.0.0.1",
"1.2.3",
"site.example:443",
"site..example",
"site.example.",
] {
assert!(normalize_web_host(invalid, "host").is_err(), "{invalid}");
}
}
+2
View File
@@ -52,3 +52,5 @@ mod synlimit_mss_tests;
mod tls_fetch_tests;
#[path = "load_basic_tests/upstream_tests.rs"]
mod upstream_tests;
#[path = "load_basic_tests/web_tests.rs"]
mod web_tests;
@@ -0,0 +1,108 @@
use super::*;
const WEB_CONFIG: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
carrier = "https-lanes"
[[web.vhosts]]
host = "Proxy.Example.COM"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
max_sessions = 4
max_streams = 64
max_streams_per_session = 16
"#;
#[test]
fn web_config_builds_canonical_runtime_snapshot() {
let config = load_config_from_temp_toml(WEB_CONFIG);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime
.vhosts
.get("proxy.example.com")
.expect("canonical WEB vhost");
assert_eq!(vhost.profiles.len(), 1);
assert_eq!(vhost.profiles[0].user, "alice");
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
assert_eq!(vhost.profiles[0].carrier, WebCarrier::HttpsLanes);
assert_eq!(vhost.profiles[0].max_sessions, 4);
assert_eq!(vhost.profiles[0].max_streams, 64);
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
}
#[test]
fn https_lanes_requires_separate_poll_and_control_handler_capacity() {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_handlers = 1\nmax_body_readers = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web.carrier=https-lanes requires"));
}
#[test]
fn web_listener_requires_an_explicit_trusted_proxy() {
let invalid = WEB_CONFIG.replace(
"web_trusted_proxy_cidrs = [\"127.0.0.1/32\"]",
"web_trusted_proxy_cidrs = []",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web_trusted_proxy_cidrs must be non-empty"));
}
#[test]
fn web_queue_limits_preserve_control_and_uplink_progress() {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\n\n[web.limits]\ncontrol_bytes_per_session = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("control reserves must cover bounded control frames"));
}
#[test]
fn web_semaphore_limits_are_rejected_before_runtime_construction() {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
&format!(
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_connections = {}",
tokio::sync::Semaphore::MAX_PERMITS + 1,
),
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("exceeds Tokio semaphore capacity"));
}
#[test]
fn web_ipv6_decoy_uses_a_valid_http_authority() {
let ipv6 = WEB_CONFIG.replace(
"http://127.0.0.1:18081",
"http://[::1]:18081",
);
let config = load_config_from_temp_toml(&ipv6);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
let WebRuntimeDecoy::HttpUpstream { authority, .. } = &vhost.decoy else {
panic!("expected HTTP decoy");
};
assert_eq!(authority, "[::1]:18081");
}
+12 -1
View File
@@ -23,6 +23,7 @@ mod logging;
mod network;
mod policies;
mod server;
mod web;
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
#[allow(unused_imports)]
@@ -43,7 +44,17 @@ pub use policies::{
pub use server::{
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile,
ListenerConfig, ServerConfig, SynLimitMode, TimeoutsConfig,
ListenerConfig, ListenerTransport, ServerConfig, SynLimitMode, TimeoutsConfig,
WebClientIpSource,
};
#[allow(unused_imports)]
pub use web::{
WebCarrier, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebTimeoutsConfig, WebVhostConfig,
};
pub(crate) use web::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite,
};
fn default_quota_state_path() -> PathBuf {
+29
View File
@@ -75,6 +75,26 @@ pub enum SynLimitMode {
Pf,
}
/// Application protocol accepted by one process-owned TCP listener.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ListenerTransport {
/// Existing MTProxy TCP listener behavior.
#[default]
Mtproxy,
/// Plain HTTP WEB gateway behind a trusted TLS terminator.
Web,
}
/// Trusted L7 source used to recover a WEB client's identity address.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WebClientIpSource {
/// Require exactly one canonical IP in `X-Forwarded-For`.
#[default]
XForwardedFor,
}
impl Serialize for SynLimitMode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
@@ -380,6 +400,9 @@ impl Default for TimeoutsConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenerConfig {
pub ip: IpAddr,
/// Application protocol accepted by this listener.
#[serde(default)]
pub transport: ListenerTransport,
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)]
pub port: Option<u16>,
@@ -429,6 +452,12 @@ pub struct ListenerConfig {
/// Default is false for safety.
#[serde(default)]
pub reuse_allow: bool,
/// L7 header policy used only by WEB listeners.
#[serde(default)]
pub web_client_ip_source: WebClientIpSource,
/// Immediate socket peers allowed to provide the WEB client identity header.
#[serde(default)]
pub web_trusted_proxy_cidrs: Vec<IpNetwork>,
}
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
+460
View File
@@ -0,0 +1,460 @@
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
/// Client-facing secret representation used to derive a WEB capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebSecretMode {
/// Use the existing 16-byte access secret without a prefix.
Plain,
/// Prefix the existing access secret with `0xdd` for capability derivation.
Dd,
}
/// HTTP carrier selected for newly issued WEB bridge sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
Https,
/// Give every logical stream independent HTTPS sequencing and polling state.
HttpsLanes,
}
impl WebCarrier {
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
}
}
}
/// One access user explicitly exposed through a WEB virtual host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebProfileConfig {
/// Existing `[access.users]` key authenticated by the inner MTProxy handshake.
pub user: String,
/// Exact client-facing secret representation advertised in WEB links.
pub secret_mode: WebSecretMode,
/// Optional per-profile live session ceiling.
#[serde(default)]
pub max_sessions: Option<usize>,
/// Optional per-profile live logical-stream ceiling.
#[serde(default)]
pub max_streams: Option<usize>,
/// Optional per-profile stream ceiling for one session.
#[serde(default)]
pub max_streams_per_session: Option<usize>,
}
/// Public-site fallback used for requests that are not authenticated WEB traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum WebDecoyConfig {
/// Stream requests to one fixed private HTTP origin.
HttpUpstream {
/// Origin URL without a query or fragment.
upstream: String,
},
/// Serve an immutable, bounded snapshot of a local directory.
StaticDirectory {
/// Absolute directory containing public files.
directory: PathBuf,
/// File served for `/` and directory paths.
#[serde(default = "default_web_static_index")]
index: String,
},
}
/// One externally visible WEB hostname and its explicit access profiles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebVhostConfig {
/// Canonical lowercase ACE hostname used by Telegram Desktop.
pub host: String,
/// Stable public destination tuple used by inner relay routing and KDF metadata.
pub public_addr: SocketAddr,
/// Ordinary-site fallback for this hostname.
pub decoy: WebDecoyConfig,
/// Access users and exact secret modes enabled for this hostname.
#[serde(default)]
pub profiles: Vec<WebProfileConfig>,
}
/// Hard process and protocol limits for WEB ingress.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebLimitsConfig {
/// Maximum bytes accepted while parsing one HTTP request head.
#[serde(default = "default_web_max_header_bytes")]
pub max_header_bytes: usize,
/// Maximum collected carrier request body size.
#[serde(default = "default_web_max_body_bytes")]
pub max_body_bytes: usize,
/// Maximum payload carried by one WEB frame.
#[serde(default = "default_web_max_frame_payload_bytes")]
pub max_frame_payload_bytes: usize,
/// Maximum encoded downlink batch returned by one poll.
#[serde(default = "default_web_carrier_batch_bytes")]
pub carrier_batch_bytes: usize,
/// Maximum frame count parsed or emitted in one carrier body.
#[serde(default = "default_web_max_frames_per_body")]
pub max_frames_per_body: usize,
/// Process-wide accepted WEB HTTP connection ceiling.
#[serde(default = "default_web_max_http_connections")]
pub max_http_connections: usize,
/// Process-wide concurrently executing HTTP handler ceiling.
#[serde(default = "default_web_max_http_handlers")]
pub max_http_handlers: usize,
/// Process-wide concurrently collected request body ceiling.
#[serde(default = "default_web_max_body_readers")]
pub max_body_readers: usize,
/// Process-wide byte reservation for collected request bodies.
#[serde(default = "default_web_max_body_bytes_global")]
pub max_body_bytes_global: usize,
/// Process-wide live WEB session ceiling.
#[serde(default = "default_web_max_sessions_global")]
pub max_sessions_global: usize,
/// Live WEB session ceiling for one forwarded client address.
#[serde(default = "default_web_max_sessions_per_ip")]
pub max_sessions_per_ip: usize,
/// Default live logical-stream ceiling for one WEB session.
#[serde(default = "default_web_max_streams_per_session")]
pub max_streams_per_session: usize,
/// Process-wide live logical-stream ceiling.
#[serde(default = "default_web_max_streams_global")]
pub max_streams_global: usize,
/// Process-wide concurrent inner MTProxy handshake ceiling.
#[serde(default = "default_web_max_stream_handshakes")]
pub max_stream_handshakes: usize,
/// Closed stream identifiers retained by one session.
#[serde(default = "default_web_max_tombstones")]
pub max_tombstones_per_session: usize,
/// Total queued data and control bytes allowed for one session.
#[serde(default = "default_web_pending_bytes_per_session")]
pub pending_bytes_per_session: usize,
/// Process-wide queued data and control byte ceiling.
#[serde(default = "default_web_pending_bytes_global")]
pub pending_bytes_global: usize,
/// Total queued data and control item ceiling for one session.
#[serde(default = "default_web_pending_items_per_session")]
pub pending_items_per_session: usize,
/// Process-wide queued data and control item ceiling.
#[serde(default = "default_web_pending_items_global")]
pub pending_items_global: usize,
/// Per-session byte reserve available only to control frames.
#[serde(default = "default_web_control_bytes_per_session")]
pub control_bytes_per_session: usize,
/// Process-wide byte reserve available only to control frames.
#[serde(default = "default_web_control_bytes_global")]
pub control_bytes_global: usize,
/// Process-wide live bootstrap credential ceiling.
#[serde(default = "default_web_max_bootstraps_global")]
pub max_bootstraps_global: usize,
/// Live bootstrap credential ceiling for one forwarded client address.
#[serde(default = "default_web_max_bootstraps_per_ip")]
pub max_bootstraps_per_ip: usize,
/// Maximum configured WEB virtual-host count.
#[serde(default = "default_web_max_vhosts")]
pub max_vhosts: usize,
/// Maximum configured WEB access-profile count across all virtual hosts.
#[serde(default = "default_web_max_profiles")]
pub max_profiles: usize,
/// Maximum static snapshot entry count across all virtual hosts.
#[serde(default = "default_web_max_static_files")]
pub max_static_files: usize,
/// Maximum bytes read from one static snapshot file.
#[serde(default = "default_web_max_static_file_bytes")]
pub max_static_file_bytes: usize,
/// Maximum static snapshot bytes across all virtual hosts.
#[serde(default = "default_web_max_static_bytes")]
pub max_static_bytes: usize,
/// Declared process envelope for HTTP heads, bodies, queues, and static snapshots.
#[serde(default = "default_web_memory_envelope_bytes")]
pub memory_envelope_bytes: usize,
/// Sustained process-wide bootstrap issuance rate.
#[serde(default = "default_web_new_bootstraps_per_minute")]
pub new_bootstraps_per_minute: u32,
/// Process-wide bootstrap issuance burst.
#[serde(default = "default_web_new_bootstraps_burst")]
pub new_bootstraps_burst: u32,
/// Sustained process-wide session creation rate.
#[serde(default = "default_web_new_sessions_per_minute")]
pub new_sessions_per_minute: u32,
/// Process-wide session creation burst.
#[serde(default = "default_web_new_sessions_burst")]
pub new_sessions_burst: u32,
/// Sustained process-wide logical-stream creation rate.
#[serde(default = "default_web_new_streams_per_minute")]
pub new_streams_per_minute: u32,
/// Process-wide logical-stream creation burst.
#[serde(default = "default_web_new_streams_burst")]
pub new_streams_burst: u32,
}
impl Default for WebLimitsConfig {
fn default() -> Self {
Self {
max_header_bytes: default_web_max_header_bytes(),
max_body_bytes: default_web_max_body_bytes(),
max_frame_payload_bytes: default_web_max_frame_payload_bytes(),
carrier_batch_bytes: default_web_carrier_batch_bytes(),
max_frames_per_body: default_web_max_frames_per_body(),
max_http_connections: default_web_max_http_connections(),
max_http_handlers: default_web_max_http_handlers(),
max_body_readers: default_web_max_body_readers(),
max_body_bytes_global: default_web_max_body_bytes_global(),
max_sessions_global: default_web_max_sessions_global(),
max_sessions_per_ip: default_web_max_sessions_per_ip(),
max_streams_per_session: default_web_max_streams_per_session(),
max_streams_global: default_web_max_streams_global(),
max_stream_handshakes: default_web_max_stream_handshakes(),
max_tombstones_per_session: default_web_max_tombstones(),
pending_bytes_per_session: default_web_pending_bytes_per_session(),
pending_bytes_global: default_web_pending_bytes_global(),
pending_items_per_session: default_web_pending_items_per_session(),
pending_items_global: default_web_pending_items_global(),
control_bytes_per_session: default_web_control_bytes_per_session(),
control_bytes_global: default_web_control_bytes_global(),
max_bootstraps_global: default_web_max_bootstraps_global(),
max_bootstraps_per_ip: default_web_max_bootstraps_per_ip(),
max_vhosts: default_web_max_vhosts(),
max_profiles: default_web_max_profiles(),
max_static_files: default_web_max_static_files(),
max_static_file_bytes: default_web_max_static_file_bytes(),
max_static_bytes: default_web_max_static_bytes(),
memory_envelope_bytes: default_web_memory_envelope_bytes(),
new_bootstraps_per_minute: default_web_new_bootstraps_per_minute(),
new_bootstraps_burst: default_web_new_bootstraps_burst(),
new_sessions_per_minute: default_web_new_sessions_per_minute(),
new_sessions_burst: default_web_new_sessions_burst(),
new_streams_per_minute: default_web_new_streams_per_minute(),
new_streams_burst: default_web_new_streams_burst(),
}
}
}
/// Deadlines for WEB HTTP, bootstrap, session, and shutdown lifecycle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebTimeoutsConfig {
/// Deadline for receiving one complete HTTP request head.
#[serde(default = "default_web_header_timeout_secs")]
pub header_secs: u64,
/// Deadline for collecting one authenticated carrier request body.
#[serde(default = "default_web_body_timeout_secs")]
pub body_secs: u64,
/// Deadline for the inner MTProxy handshake on one logical stream.
#[serde(default = "default_web_stream_handshake_timeout_secs")]
pub stream_handshake_secs: u64,
/// Maximum wait for one empty downlink long poll.
#[serde(default = "default_web_long_poll_timeout_secs")]
pub long_poll_secs: u64,
/// Lifetime of an unused bootstrap credential and closed-token replay marker.
#[serde(default = "default_web_bootstrap_lifetime_secs")]
pub bootstrap_lifetime_secs: u64,
/// Maximum carrier inactivity before a session is closed.
#[serde(default = "default_web_reconnect_grace_secs")]
pub reconnect_grace_secs: u64,
/// Maximum idle lifetime of a WEB HTTP keep-alive connection.
#[serde(default = "default_web_http_idle_secs")]
pub http_idle_secs: u64,
/// Maximum graceful wait for WEB connections and process-owned tasks.
#[serde(default = "default_web_shutdown_secs")]
pub shutdown_secs: u64,
/// Deadline for connecting to and receiving headers from an HTTP decoy.
#[serde(default = "default_web_decoy_header_timeout_secs")]
pub decoy_header_secs: u64,
}
impl Default for WebTimeoutsConfig {
fn default() -> Self {
Self {
header_secs: default_web_header_timeout_secs(),
body_secs: default_web_body_timeout_secs(),
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
long_poll_secs: default_web_long_poll_timeout_secs(),
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
reconnect_grace_secs: default_web_reconnect_grace_secs(),
http_idle_secs: default_web_http_idle_secs(),
shutdown_secs: default_web_shutdown_secs(),
decoy_header_secs: default_web_decoy_header_timeout_secs(),
}
}
}
/// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebConfig {
/// Enables issuance of new WEB bridge and session credentials.
#[serde(default)]
pub enabled: bool,
/// Carrier selected for newly issued WEB bridge sessions.
#[serde(default)]
pub carrier: WebCarrier,
/// Hard process and protocol limits.
#[serde(default)]
pub limits: WebLimitsConfig,
/// WEB lifecycle deadlines.
#[serde(default)]
pub timeouts: WebTimeoutsConfig,
/// Public hostnames served by WEB listeners.
#[serde(default)]
pub vhosts: Vec<WebVhostConfig>,
/// Validated immutable runtime snapshot built during configuration loading.
#[serde(skip)]
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
}
/// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)]
pub(crate) struct WebRuntimeConfig {
/// Canonical host lookup used by HTTP request routing.
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
/// Flat profile inventory used by startup link emission.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
}
/// Precomputed immutable virtual-host data.
#[derive(Debug)]
pub(crate) struct WebRuntimeVhost {
/// Canonical lowercase ACE hostname.
pub(crate) host: String,
/// Immutable ordinary-site fallback snapshot.
pub(crate) decoy: WebRuntimeDecoy,
/// Upstream connect and response-head deadline.
pub(crate) decoy_header_secs: u64,
/// Exact capability profiles accepted by this host.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
}
/// Precomputed exact-user capability entry.
#[derive(Debug)]
pub(crate) struct WebRuntimeProfile {
/// Canonical host that owns this profile.
pub(crate) host: String,
/// Stable public destination tuple supplied to relay routing.
pub(crate) public_addr: SocketAddr,
/// Exact access user authenticated by logical streams.
pub(crate) user: String,
/// Client secret representation and inner protocol policy.
pub(crate) secret_mode: WebSecretMode,
/// Carrier frozen into bridge and session state at issuance time.
pub(crate) carrier: WebCarrier,
/// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32],
/// Per-profile live session ceiling.
pub(crate) max_sessions: usize,
/// Per-profile live logical-stream ceiling.
pub(crate) max_streams: usize,
/// Per-session live relay-task ceiling.
pub(crate) max_streams_per_session: usize,
}
/// Runtime-ready ordinary-site fallback.
#[derive(Debug)]
pub(crate) enum WebRuntimeDecoy {
HttpUpstream {
addr: SocketAddr,
authority: String,
},
StaticDirectory(Arc<WebStaticSite>),
}
/// Immutable bounded static-site snapshot.
#[derive(Debug)]
pub(crate) struct WebStaticSite {
/// Canonical URL-path to immutable response asset mapping.
pub(crate) assets: BTreeMap<String, WebStaticAsset>,
/// Configured root index file name.
pub(crate) index: String,
}
/// One immutable static response body and metadata.
#[derive(Debug)]
pub(crate) struct WebStaticAsset {
/// Immutable response body retained by the runtime snapshot.
pub(crate) body: Bytes,
/// Extension-derived static content type.
pub(crate) content_type: &'static str,
/// Strong SHA-256 entity tag.
pub(crate) etag: String,
}
fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
fn $name() -> u32 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frames_per_body, 4096);
usize_default!(default_web_max_http_connections, 1024);
usize_default!(default_web_max_http_handlers, 512);
usize_default!(default_web_max_body_readers, 32);
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_max_sessions_global, 128);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
u64_default!(default_web_header_timeout_secs, 10);
u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_long_poll_timeout_secs, 25);
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);
+18 -1
View File
@@ -14,6 +14,7 @@ use crate::ip_tracker::UserIpTracker;
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::proxy::authenticated::ClientRuntimeDeps;
use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool;
@@ -227,6 +228,22 @@ impl RuntimeGeneration {
self.me_pool_runtime.read().await.clone()
}
/// Pins all dependencies required by a client stream without retaining the generation.
pub(crate) fn client_runtime_deps(&self) -> ClientRuntimeDeps {
ClientRuntimeDeps {
config: self.config(),
stats: Arc::clone(&self.stats),
upstream_manager: Arc::clone(&self.upstream_manager),
buffer_pool: Arc::clone(&self.buffer_pool),
rng: Arc::clone(&self.rng),
me_pool: self.me_pool.clone(),
me_pool_runtime: Some(Arc::clone(&self.me_pool_runtime)),
route_runtime: Arc::clone(&self.route_runtime),
ip_tracker: Arc::clone(&self.ip_tracker),
shared: Arc::clone(&self.proxy_shared),
}
}
/// Registers a session only while admission remains open.
pub(crate) fn spawn_session<F>(&self, future: F) -> bool
where
@@ -287,7 +304,7 @@ impl RuntimeGeneration {
#[cfg(test)]
/// Builds a lightweight runtime generation without network startup tasks.
pub(super) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc<RuntimeGeneration> {
pub(crate) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc<RuntimeGeneration> {
let (config_tx, config_rx) = watch::channel(Arc::new(config.clone()));
let (_admission_tx, admission_rx) = watch::channel(true);
let stats = Arc::new(Stats::new());
+40
View File
@@ -607,6 +607,46 @@ pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
}
}
/// Prints WEB links only for profiles selected by the existing link policy.
pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
if !config.web.enabled || config.general.links.show.is_empty() {
return;
}
let Some(runtime) = config.web.runtime.as_ref() else {
return;
};
let shown = config
.general
.links
.show
.resolve_users(&config.access.users);
let mut heading_printed = false;
for profile in &runtime.profiles {
if !shown.iter().any(|user| user.as_str() == profile.user) {
continue;
}
if !heading_printed {
print_maestro_line("WEB proxy links");
heading_printed = true;
}
let Some(secret) = config.access.users.get(&profile.user) else {
continue;
};
let prefix = match profile.secret_mode {
crate::config::WebSecretMode::Plain => "",
crate::config::WebSecretMode::Dd => "dd",
};
print_maestro_line(format!(
"User: {} ({:?})",
profile.user, profile.secret_mode
));
print_maestro_line(format!(
"WEB: tg://webproxy?server={}&secret={prefix}{secret}",
profile.host,
));
}
}
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
if let Some(parent) = std::path::Path::new(path).parent()
&& !parent.as_os_str().is_empty()
+58 -2
View File
@@ -6,11 +6,13 @@ use tokio::net::{TcpListener, TcpStream};
use tokio::sync::OwnedSemaphorePermit;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use tracing::{debug, error, info, warn};
use crate::config::RstOnCloseMode;
use crate::config::{ListenerTransport, RstOnCloseMode};
use crate::proxy::ClientHandler;
use crate::transport::socket::set_linger_zero;
use crate::web::manager::WebProcessRuntime;
use super::bind::BoundTcpListener;
use super::plan::ListenerBindSpec;
@@ -19,11 +21,15 @@ use crate::maestro::helpers::{
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
};
/// One bound listener and all connection tasks accepted through its lifecycle.
pub(super) struct ListenerSlot {
pub(super) spec: ListenerBindSpec,
listener: Arc<TcpListener>,
cancellation: CancellationToken,
task: Option<JoinHandle<()>>,
connections: TaskTracker,
web_runtime: Option<Arc<WebProcessRuntime>>,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
}
enum PermitWait {
@@ -181,6 +187,8 @@ async fn run_accept_loop(
listener: Arc<TcpListener>,
spec: ListenerBindSpec,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
web_runtime: Option<Arc<WebProcessRuntime>>,
connections: TaskTracker,
cancellation: CancellationToken,
) {
loop {
@@ -191,6 +199,26 @@ async fn run_accept_loop(
};
match accepted {
Ok((stream, peer_addr)) => {
if spec.transport == ListenerTransport::Web {
let Some(web_runtime) = web_runtime.as_ref() else {
error!(addr = %spec.addr, "WEB listener has no process runtime");
return;
};
let Some(connection_permit) = web_runtime.try_http_connection() else {
drop(stream);
continue;
};
connections.spawn(crate::web::http::serve_connection(
stream,
peer_addr,
spec.web_client_ip_source,
Arc::clone(&spec.web_trusted_proxy_cidrs),
Arc::clone(web_runtime),
cancellation.clone(),
connection_permit,
));
continue;
}
let runtime = active_runtime.load_full();
if !*runtime.admission_rx.borrow() {
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
@@ -232,12 +260,16 @@ impl ListenerSlot {
pub(super) fn start(
bound: BoundTcpListener,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
web_runtime: Option<Arc<WebProcessRuntime>>,
) -> Self {
let cancellation = CancellationToken::new();
let connections = TaskTracker::new();
let task = tokio::spawn(run_accept_loop(
bound.listener.clone(),
bound.spec.clone(),
active_runtime,
active_runtime.clone(),
web_runtime.clone(),
connections.clone(),
cancellation.clone(),
));
Self {
@@ -245,6 +277,9 @@ impl ListenerSlot {
listener: bound.listener,
cancellation,
task: Some(task),
connections,
web_runtime,
active_runtime,
}
}
@@ -255,15 +290,36 @@ impl ListenerSlot {
format!("listener {} task failed: {error_value}", self.spec.addr)
})?;
}
self.connections.close();
let connection_stop_timeout = Duration::from_secs(
self.active_runtime
.load()
.config()
.web
.timeouts
.shutdown_secs,
);
tokio::time::timeout(connection_stop_timeout, self.connections.wait())
.await
.map_err(|_| {
format!(
"listener {} connection shutdown timed out",
self.spec.addr
)
})?;
Ok(())
}
pub(super) fn restart(&mut self, active_runtime: Arc<ArcSwap<RuntimeGeneration>>) {
self.active_runtime = active_runtime.clone();
self.cancellation = CancellationToken::new();
self.connections = TaskTracker::new();
self.task = Some(tokio::spawn(run_accept_loop(
self.listener.clone(),
self.spec.clone(),
active_runtime,
self.web_runtime.clone(),
self.connections.clone(),
self.cancellation.clone(),
)));
}
+15 -5
View File
@@ -8,13 +8,13 @@ use tokio::net::TcpListener;
use tokio::net::UnixListener;
use tracing::{error, info, warn};
use crate::config::ProxyConfig;
use crate::config::{ListenerTransport, ProxyConfig};
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
use crate::transport::find_listener_processes;
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
use super::plan::{ListenerBindSpec, listener_bind_plan};
use crate::maestro::helpers::print_proxy_links;
use crate::maestro::helpers::{print_proxy_links, print_web_proxy_links};
/// Owns sockets bound before process accept loops start.
pub(crate) struct BoundListeners {
@@ -57,7 +57,8 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
config
.server
.listeners
.first()
.iter()
.find(|listener| listener.transport == ListenerTransport::Mtproxy)
.and_then(|listener| listener.port)
.unwrap_or(config.server.port)
}
@@ -110,7 +111,7 @@ impl PreparedTcpListener {
}
fn log_listener_profile(spec: &ListenerBindSpec) {
info!(addr = %spec.addr, "Listening on TCP endpoint");
info!(addr = %spec.addr, transport = ?spec.transport, "Listening on TCP endpoint");
if let Some(client_mss) = spec.options.client_mss {
info!(
addr = %spec.addr,
@@ -135,7 +136,11 @@ fn print_configured_links(
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
) {
print_web_proxy_links(config);
for listener in &config.server.listeners {
if listener.transport != ListenerTransport::Mtproxy {
continue;
}
let port = listener.port.unwrap_or(config.server.port);
let addr = SocketAddr::new(listener.ip, port);
if !plan.contains_key(&addr) || config.general.links.public_host.is_some() {
@@ -160,7 +165,12 @@ fn print_configured_links(
}
}
if config.general.links.show.is_empty() || config.general.links.public_host.is_none() {
if config.general.links.show.is_empty()
|| config.general.links.public_host.is_none()
|| !plan
.values()
.any(|spec| spec.transport == ListenerTransport::Mtproxy)
{
return;
}
let host = config
+48 -2
View File
@@ -5,11 +5,13 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::config::ProxyConfig;
use crate::config::ListenerTransport;
use crate::maestro::generation::RuntimeGeneration;
use super::accept::ListenerSlot;
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
use super::plan::{ListenerBindSpec, listener_bind_plan};
use crate::web::manager::WebProcessRuntime;
#[cfg(unix)]
use super::unix::UnixAcceptHandle;
@@ -17,16 +19,19 @@ use super::unix::UnixAcceptHandle;
pub(crate) struct ListenerManager {
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
slots: BTreeMap<SocketAddr, ListenerSlot>,
web_runtime: Option<Arc<WebProcessRuntime>>,
#[cfg(unix)]
unix: Option<UnixAcceptHandle>,
}
/// Socket changes prepared without activating or stopping accept loops.
pub(crate) struct PreparedListenerTransition {
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
additions: Vec<PreparedTcpListener>,
removals: Vec<SocketAddr>,
}
/// Activated additions and stopped removals awaiting runtime publication.
pub(crate) struct PendingListenerTransition {
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
additions: Vec<BoundTcpListener>,
@@ -39,10 +44,22 @@ impl ListenerManager {
bound: BoundListeners,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
) -> Self {
let has_web = bound
.listeners
.iter()
.any(|listener| listener.spec.transport == ListenerTransport::Web);
let web_runtime = has_web.then(|| WebProcessRuntime::start(active_runtime.clone()));
let mut slots = BTreeMap::new();
for listener in bound.listeners {
let addr = listener.spec.addr;
slots.insert(addr, ListenerSlot::start(listener, active_runtime.clone()));
slots.insert(
addr,
ListenerSlot::start(
listener,
active_runtime.clone(),
web_runtime.clone(),
),
);
}
#[cfg(unix)]
let unix = bound
@@ -51,6 +68,7 @@ impl ListenerManager {
Self {
active_runtime,
slots,
web_runtime,
#[cfg(unix)]
unix,
}
@@ -61,6 +79,7 @@ impl ListenerManager {
Self {
active_runtime,
slots: BTreeMap::new(),
web_runtime: None,
#[cfg(unix)]
unix: None,
}
@@ -72,6 +91,20 @@ impl ListenerManager {
desired: &ProxyConfig,
) -> Result<Option<PreparedListenerTransition>, String> {
let target_specs = listener_bind_plan(desired)?;
let web_inventory_changed = self
.slots
.iter()
.filter(|(_, slot)| slot.spec.transport == ListenerTransport::Web)
.map(|(addr, slot)| (*addr, slot.spec.clone()))
.collect::<BTreeMap<_, _>>()
!= target_specs
.iter()
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
.map(|(addr, spec)| (*addr, spec.clone()))
.collect::<BTreeMap<_, _>>();
if web_inventory_changed {
return Err("WEB listener inventory is process-owned; process restart required".to_string());
}
let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
if current_addresses == target_addresses
@@ -164,7 +197,11 @@ impl ListenerManager {
let addr = listener.spec.addr;
self.slots.insert(
addr,
ListenerSlot::start(listener, self.active_runtime.clone()),
ListenerSlot::start(
listener,
self.active_runtime.clone(),
self.web_runtime.clone(),
),
);
}
debug_assert_eq!(
@@ -191,6 +228,9 @@ impl ListenerManager {
errors.push(error_value);
}
self.slots.clear();
if let Some(web_runtime) = self.web_runtime.take() {
web_runtime.shutdown().await;
}
#[cfg(unix)]
{
self.unix = None;
@@ -214,6 +254,7 @@ mod tests {
fn listener_config(addr: SocketAddr) -> ListenerConfig {
ListenerConfig {
ip: addr.ip(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(addr.port()),
client_mss: None,
synlimit: SynLimitMode::Off,
@@ -229,6 +270,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
@@ -237,12 +280,15 @@ mod tests {
let addr = listener.local_addr().unwrap();
let spec = ListenerBindSpec {
addr,
transport: crate::config::ListenerTransport::Mtproxy,
options: ListenOptions {
reuse_port: false,
..Default::default()
},
proxy_protocol: false,
tls_response_fragment_size: None,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Arc::from([]),
};
(
BoundTcpListener {
+41 -5
View File
@@ -1,7 +1,10 @@
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr;
use std::sync::Arc;
use crate::config::{ProxyConfig, ServerConfig, SynLimitMode};
use crate::config::{
ListenerTransport, ProxyConfig, ServerConfig, SynLimitMode, WebClientIpSource,
};
use crate::transport::ListenOptions;
use super::tcp_mss_runtime_profile;
@@ -10,9 +13,12 @@ use super::tcp_mss_runtime_profile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ListenerBindSpec {
pub(super) addr: SocketAddr,
pub(super) transport: ListenerTransport,
pub(super) options: ListenOptions,
pub(super) proxy_protocol: bool,
pub(super) tls_response_fragment_size: Option<u16>,
pub(super) web_client_ip_source: WebClientIpSource,
pub(super) web_trusted_proxy_cidrs: Arc<[ipnetwork::IpNetwork]>,
}
fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, server: &ServerConfig) -> u16 {
@@ -40,16 +46,24 @@ pub(crate) fn listener_bind_plan(
if addr.is_ipv6() && config.network.ipv6 == Some(false) {
continue;
}
let configured_client_mss = listener
.effective_client_mss(&config.server)
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?;
let configured_client_mss = if listener.transport == ListenerTransport::Web {
None
} else {
listener
.effective_client_mss(&config.server)
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?
};
let listener_bulk_mss = (listener.transport != ListenerTransport::Web)
.then_some(bulk_client_mss)
.flatten();
#[cfg(target_os = "linux")]
let (client_mss, tls_response_fragment_size) =
tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss);
tcp_mss_runtime_profile(configured_client_mss, listener_bulk_mss);
#[cfg(not(target_os = "linux"))]
let (client_mss, tls_response_fragment_size) = (configured_client_mss, None);
let spec = ListenerBindSpec {
addr,
transport: listener.transport,
options: ListenOptions {
reuse_port: listener.reuse_allow,
ipv6_only: listener.ip.is_ipv6(),
@@ -61,6 +75,8 @@ pub(crate) fn listener_bind_plan(
.proxy_protocol
.unwrap_or(config.server.proxy_protocol),
tls_response_fragment_size,
web_client_ip_source: listener.web_client_ip_source,
web_trusted_proxy_cidrs: Arc::from(listener.web_trusted_proxy_cidrs.clone()),
};
if plan.insert(addr, spec).is_some() {
return Err(format!("duplicate effective listener endpoint: {addr}"));
@@ -80,6 +96,23 @@ fn any_synlimit_enabled(config: &ProxyConfig) -> bool {
/// Returns whether an endpoint-only change can use coordinated process rebind.
pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool {
let Ok(old_plan) = listener_bind_plan(old) else {
return false;
};
let Ok(desired_plan) = listener_bind_plan(desired) else {
return false;
};
let old_web = old_plan
.iter()
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
.collect::<BTreeMap<_, _>>();
let desired_web = desired_plan
.iter()
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
.collect::<BTreeMap<_, _>>();
if old_web != desired_web {
return false;
}
if any_synlimit_enabled(old) || any_synlimit_enabled(desired) {
return false;
}
@@ -107,6 +140,7 @@ mod tests {
fn listener(ip: &str, port: u16) -> ListenerConfig {
ListenerConfig {
ip: ip.parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(port),
client_mss: None,
synlimit: SynLimitMode::Off,
@@ -122,6 +156,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
+3
View File
@@ -131,6 +131,7 @@ mod tests {
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
ListenerConfig {
ip: "127.0.0.1".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit,
@@ -146,6 +147,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
+10
View File
@@ -406,6 +406,16 @@ pub(crate) fn resolve_reload_config(
fields.push("logging".to_string());
effective.logging = old.logging.clone();
}
if serde_json::to_value(&old.web.limits).ok()
!= serde_json::to_value(&desired.web.limits).ok()
{
fields.push("web.limits".to_string());
effective.web.limits = old.web.limits.clone();
if effective.rebuild_runtime_web().is_err() {
fields.push("web".to_string());
effective.web = old.web.clone();
}
}
let runtime_changed = !configs_equal(old, &effective);
ResolvedReloadConfig {
effective,
+27
View File
@@ -3,6 +3,7 @@ use super::*;
fn test_listener(port: u16) -> crate::config::ListenerConfig {
crate::config::ListenerConfig {
ip: "127.0.0.1".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(port),
client_mss: None,
synlimit: crate::config::SynLimitMode::Off,
@@ -18,6 +19,8 @@ fn test_listener(port: u16) -> crate::config::ListenerConfig {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
@@ -80,6 +83,7 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
let mut old = ProxyConfig::default();
old.server.listeners.push(crate::config::ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit: crate::config::SynLimitMode::Off,
@@ -95,6 +99,8 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
let mut desired = old.clone();
desired.server.listeners[0].announce = Some("proxy.example".to_string());
@@ -143,6 +149,27 @@ fn runtime_only_change_does_not_require_process_rebind() {
assert!(deferred_process_fields(&old, &new).is_empty());
}
#[test]
fn web_allocation_limits_are_deferred_until_restart() {
let mut old = ProxyConfig::default();
old.rebuild_runtime_user_auth().unwrap();
old.rebuild_runtime_web().unwrap();
let mut desired = old.clone();
desired.web.limits.max_sessions_global += 1;
let resolved = resolve_reload_config(&old, &desired);
assert_eq!(
resolved.deferred_process_fields,
vec!["web.limits".to_string()]
);
assert_eq!(
resolved.effective.web.limits.max_sessions_global,
old.web.limits.max_sessions_global
);
assert!(!resolved.runtime_changed);
}
#[test]
fn strict_middle_proxy_requires_a_prepared_pool() {
assert!(strict_middle_proxy_unavailable(true, false, false));
+1
View File
@@ -34,6 +34,7 @@ mod synlimit_control;
mod tls_front;
mod transport;
mod util;
mod web;
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
// Install rustls crypto provider early
+325
View File
@@ -0,0 +1,325 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::RwLock;
use tracing::warn;
use crate::config::ProxyConfig;
use crate::crypto::SecureRandom;
use crate::error::{ProxyError, Result};
use crate::ip_tracker::UserIpTracker;
use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack;
use crate::proxy::handshake::HandshakeSuccess;
use crate::proxy::middle_relay::{
handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack,
};
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
use crate::stats::Stats;
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
/// Immutable dependency snapshot pinned by one authenticated client stream.
#[derive(Clone)]
pub(crate) struct ClientRuntimeDeps {
/// Immutable effective configuration pinned for this stream.
pub(crate) config: Arc<ProxyConfig>,
/// Process statistics registry.
pub(crate) stats: Arc<Stats>,
/// Direct Telegram upstream connector.
pub(crate) upstream_manager: Arc<UpstreamManager>,
/// Shared relay buffer pool.
pub(crate) buffer_pool: Arc<BufferPool>,
/// Process cryptographic random source.
pub(crate) rng: Arc<SecureRandom>,
/// Startup Middle-End pool, when immediately available.
pub(crate) me_pool: Option<Arc<MePool>>,
/// Hot-swappable Middle-End pool holder.
pub(crate) me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
/// Route-mode controller shared by active generations.
pub(crate) route_runtime: Arc<RouteRuntimeController>,
/// Per-user source-IP admission tracker.
pub(crate) ip_tracker: Arc<UserIpTracker>,
/// Process-shared admission and relay coordination state.
pub(crate) shared: Arc<ProxySharedState>,
}
/// Runs admission and relay after a successful MTProxy handshake.
pub(crate) async fn run_authenticated<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
success: HandshakeSuccess,
deps: ClientRuntimeDeps,
local_addr: SocketAddr,
peer_addr: SocketAddr,
conntrack_close_policy: ConntrackClosePolicy,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let user = success.user.clone();
if !deps.shared.is_user_enabled(&user) {
warn!(user = %user, "Disabled user rejected");
return Err(ProxyError::UserDisabled { user });
}
let user_reservation = acquire_user_connection_reservation(
&user,
&deps.config,
Arc::clone(&deps.stats),
peer_addr,
Arc::clone(&deps.ip_tracker),
)
.await
.map_err(|error| {
warn!(user = %user, error = %error, "User admission check failed");
error
})?;
let route_snapshot = deps.route_runtime.snapshot();
let session_id = deps.rng.u64();
let user_session = deps.shared.register_user_session(&user, session_id);
let session_cancel = user_session.token();
let selected_me_pool = if deps.config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
{
if let Some(pool) = &deps.me_pool {
Some(Arc::clone(pool))
} else if let Some(pool_runtime) = &deps.me_pool_runtime {
pool_runtime.read().await.clone()
} else {
None
}
} else {
None
};
let relay_result = if deps.config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
{
if let Some(pool) = selected_me_pool {
if conntrack_close_policy == ConntrackClosePolicy::Publish {
handle_via_middle_proxy(
client_reader,
client_writer,
success,
pool,
Arc::clone(&deps.stats),
Arc::clone(&deps.config),
Arc::clone(&deps.buffer_pool),
local_addr,
Arc::clone(&deps.rng),
deps.route_runtime.subscribe(),
route_snapshot,
session_id,
session_cancel.clone(),
Arc::clone(&deps.shared),
)
.await
} else {
handle_via_middle_proxy_with_conntrack(
client_reader,
client_writer,
success,
pool,
Arc::clone(&deps.stats),
Arc::clone(&deps.config),
Arc::clone(&deps.buffer_pool),
local_addr,
Arc::clone(&deps.rng),
deps.route_runtime.subscribe(),
route_snapshot,
session_id,
session_cancel.clone(),
Arc::clone(&deps.shared),
ConntrackClosePolicy::Suppress,
)
.await
}
} else {
warn!("use_middle_proxy=true but MePool not initialized, falling back to direct");
run_direct(
client_reader,
client_writer,
success,
&deps,
route_snapshot,
session_id,
local_addr,
session_cancel.clone(),
conntrack_close_policy,
)
.await
}
} else {
run_direct(
client_reader,
client_writer,
success,
&deps,
route_snapshot,
session_id,
local_addr,
session_cancel,
conntrack_close_policy,
)
.await
};
user_reservation.release().await;
relay_result
}
async fn run_direct<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
success: HandshakeSuccess,
deps: &ClientRuntimeDeps,
route_snapshot: crate::proxy::route_mode::RouteCutoverState,
session_id: u64,
local_addr: SocketAddr,
session_cancel: tokio_util::sync::CancellationToken,
conntrack_close_policy: ConntrackClosePolicy,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
handle_via_direct_with_shared_and_conntrack(
client_reader,
client_writer,
success,
Arc::clone(&deps.upstream_manager),
Arc::clone(&deps.stats),
Arc::clone(&deps.config),
Arc::clone(&deps.buffer_pool),
Arc::clone(&deps.rng),
deps.route_runtime.subscribe(),
route_snapshot,
session_id,
local_addr,
session_cancel,
Arc::clone(&deps.shared),
conntrack_close_policy,
)
.await
}
#[must_use = "the reservation owns user and IP admission until release or drop"]
/// Owns one authenticated user's connection and source-IP admission slots.
pub(crate) struct UserConnectionReservation {
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
user: String,
ip: IpAddr,
tracks_ip: bool,
active: bool,
}
impl UserConnectionReservation {
/// Creates an active reservation after both admission counters were acquired.
pub(crate) fn new(
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
user: String,
ip: IpAddr,
tracks_ip: bool,
) -> Self {
Self {
stats,
ip_tracker,
user,
ip,
tracks_ip,
active: true,
}
}
/// Releases both admission counters through the asynchronous cleanup path.
pub(crate) async fn release(mut self) {
if !self.active {
return;
}
self.active = false;
if self.tracks_ip {
self.ip_tracker.remove_ip(&self.user, self.ip).await;
}
self.stats.decrement_user_curr_connects(&self.user);
}
}
impl Drop for UserConnectionReservation {
fn drop(&mut self) {
if !self.active {
return;
}
self.active = false;
self.stats.increment_session_drop_fallback_total();
self.stats.decrement_user_curr_connects(&self.user);
if self.tracks_ip {
self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip);
}
}
}
/// Applies user quota, connection, and source-IP admission atomically.
pub(crate) async fn acquire_user_connection_reservation(
user: &str,
config: &ProxyConfig,
stats: Arc<Stats>,
peer_addr: SocketAddr,
ip_tracker: Arc<UserIpTracker>,
) -> Result<UserConnectionReservation> {
if let Some(expiration) = config.access.user_expirations.get(user)
&& chrono::Utc::now() > *expiration
{
return Err(ProxyError::UserExpired {
user: user.to_string(),
});
}
if let Some(quota) = config.access.user_data_quota.get(user)
&& stats.get_user_quota_used(user) >= *quota
{
return Err(ProxyError::DataQuotaExceeded {
user: user.to_string(),
});
}
let limit = config
.access
.user_max_tcp_conns
.get(user)
.copied()
.filter(|limit| *limit > 0)
.or((config.access.user_max_tcp_conns_global_each > 0)
.then_some(config.access.user_max_tcp_conns_global_each))
.map(|value| value as u64);
if !stats.try_acquire_user_curr_connects(user, limit) {
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
if let Err(reason) = ip_tracker.check_and_add(user, peer_addr.ip()).await {
stats.decrement_user_curr_connects(user);
warn!(
user = %user,
ip = %peer_addr.ip(),
reason = %reason,
"IP limit exceeded"
);
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
Ok(UserConnectionReservation::new(
stats,
ip_tracker,
user.to_string(),
peer_addr.ip(),
true,
))
}
+29 -225
View File
@@ -26,72 +26,6 @@ enum HandshakeOutcome {
NeedsMasking(PostHandshakeFuture),
}
#[must_use = "UserConnectionReservation must be kept alive to retain user/IP reservation until release or drop"]
struct UserConnectionReservation {
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
user: String,
ip: IpAddr,
tracks_ip: bool,
state: SessionReservationState,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SessionReservationState {
Active,
Released,
}
impl UserConnectionReservation {
fn new(
stats: Arc<Stats>,
ip_tracker: Arc<UserIpTracker>,
user: String,
ip: IpAddr,
tracks_ip: bool,
) -> Self {
Self {
stats,
ip_tracker,
user,
ip,
tracks_ip,
state: SessionReservationState::Active,
}
}
fn mark_released(&mut self) -> bool {
if self.state != SessionReservationState::Active {
return false;
}
self.state = SessionReservationState::Released;
true
}
async fn release(mut self) {
if !self.mark_released() {
return;
}
if self.tracks_ip {
self.ip_tracker.remove_ip(&self.user, self.ip).await;
}
self.stats.decrement_user_curr_connects(&self.user);
}
}
impl Drop for UserConnectionReservation {
fn drop(&mut self) {
if !self.mark_released() {
return;
}
self.stats.increment_session_drop_fallback_total();
self.stats.decrement_user_curr_connects(&self.user);
if self.tracks_ip {
self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip);
}
}
}
use crate::config::ProxyConfig;
use crate::crypto::SecureRandom;
use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
@@ -107,7 +41,11 @@ use crate::transport::middle_proxy::MePool;
use crate::transport::socket::normalize_ip;
use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_protocol};
use crate::proxy::direct_relay::handle_via_direct_with_shared;
use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated};
#[cfg(test)]
use crate::proxy::authenticated::{
UserConnectionReservation, acquire_user_connection_reservation,
};
use crate::proxy::handshake::{
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
@@ -115,9 +53,10 @@ use crate::proxy::handshake::{
#[cfg(test)]
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
use crate::proxy::masking::handle_bad_client_with_shared;
use crate::proxy::middle_relay::handle_via_middle_proxy;
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
use crate::proxy::shared_state::ProxySharedState;
use crate::proxy::route_mode::RouteRuntimeController;
#[cfg(test)]
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
const BEOBACHTEN_TTL_MAX_MINUTES: u64 = 24 * 60;
@@ -1688,112 +1627,30 @@ impl RunningClientHandler {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let user = success.user.clone();
if !shared.is_user_enabled(&user) {
warn!(user = %user, "Disabled user rejected");
return Err(ProxyError::UserDisabled { user });
}
let user_limit_reservation = match Self::acquire_user_connection_reservation_static(
&user,
&config,
stats.clone(),
peer_addr,
ip_tracker,
)
.await
{
Ok(reservation) => reservation,
Err(e) => {
warn!(user = %user, error = %e, "User admission check failed");
return Err(e);
}
};
let route_snapshot = route_runtime.snapshot();
let session_id = rng.u64();
let _user_session = shared.register_user_session(&user, session_id);
let session_cancel = _user_session.token();
let selected_me_pool = if config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
{
if let Some(ref pool) = me_pool {
Some(pool.clone())
} else if let Some(pool_runtime) = me_pool_runtime.as_ref() {
pool_runtime.read().await.clone()
} else {
None
}
} else {
None
};
let relay_result = if config.general.use_middle_proxy
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
{
if let Some(pool) = selected_me_pool {
handle_via_middle_proxy(
client_reader,
client_writer,
success,
pool,
stats.clone(),
config,
buffer_pool,
local_addr,
rng,
route_runtime.subscribe(),
route_snapshot,
session_id,
session_cancel.clone(),
shared.clone(),
)
.await
} else {
warn!("use_middle_proxy=true but MePool not initialized, falling back to direct");
handle_via_direct_with_shared(
client_reader,
client_writer,
success,
upstream_manager,
stats.clone(),
config,
buffer_pool,
rng,
route_runtime.subscribe(),
route_snapshot,
session_id,
local_addr,
session_cancel.clone(),
shared.clone(),
)
.await
}
} else {
// Direct mode (original behavior)
handle_via_direct_with_shared(
client_reader,
client_writer,
success,
upstream_manager,
stats.clone(),
run_authenticated(
client_reader,
client_writer,
success,
ClientRuntimeDeps {
config,
stats,
upstream_manager,
buffer_pool,
rng,
route_runtime.subscribe(),
route_snapshot,
session_id,
local_addr,
session_cancel,
shared.clone(),
)
.await
};
user_limit_reservation.release().await;
relay_result
me_pool,
me_pool_runtime,
route_runtime,
ip_tracker,
shared,
},
local_addr,
peer_addr,
ConntrackClosePolicy::Publish,
)
.await
}
#[cfg(test)]
async fn acquire_user_connection_reservation_static(
user: &str,
config: &ProxyConfig,
@@ -1801,60 +1658,7 @@ impl RunningClientHandler {
peer_addr: SocketAddr,
ip_tracker: Arc<UserIpTracker>,
) -> Result<UserConnectionReservation> {
if let Some(expiration) = config.access.user_expirations.get(user)
&& chrono::Utc::now() > *expiration
{
return Err(ProxyError::UserExpired {
user: user.to_string(),
});
}
if let Some(quota) = config.access.user_data_quota.get(user)
&& stats.get_user_quota_used(user) >= *quota
{
return Err(ProxyError::DataQuotaExceeded {
user: user.to_string(),
});
}
let limit = config
.access
.user_max_tcp_conns
.get(user)
.copied()
.filter(|limit| *limit > 0)
.or((config.access.user_max_tcp_conns_global_each > 0)
.then_some(config.access.user_max_tcp_conns_global_each))
.map(|v| v as u64);
if !stats.try_acquire_user_curr_connects(user, limit) {
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
Ok(()) => {}
Err(reason) => {
stats.decrement_user_curr_connects(user);
warn!(
user = %user,
ip = %peer_addr.ip(),
reason = %reason,
"IP limit exceeded"
);
return Err(ProxyError::ConnectionLimitExceeded {
user: user.to_string(),
});
}
}
Ok(UserConnectionReservation::new(
stats,
ip_tracker,
user.to_string(),
peer_addr.ip(),
true,
))
acquire_user_connection_reservation(user, config, stats, peer_addr, ip_tracker).await
}
#[cfg(test)]
+59 -12
View File
@@ -22,7 +22,8 @@ use crate::proxy::route_mode::{
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
};
use crate::proxy::shared_state::{
ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState,
ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason,
ProxySharedState,
};
use crate::stats::Stats;
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
@@ -229,6 +230,7 @@ fn unknown_dc_test_lock() -> &'static Mutex<()> {
}
#[allow(dead_code)]
/// Runs Direct relay with standalone cancellation and shared-state defaults.
pub(crate) async fn handle_via_direct<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
@@ -265,7 +267,49 @@ where
.await
}
/// Runs Direct relay for a kernel-backed TCP client tuple.
pub(crate) async fn handle_via_direct_with_shared<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
success: HandshakeSuccess,
upstream_manager: Arc<UpstreamManager>,
stats: Arc<Stats>,
config: Arc<ProxyConfig>,
buffer_pool: Arc<BufferPool>,
rng: Arc<SecureRandom>,
route_rx: watch::Receiver<RouteCutoverState>,
route_snapshot: RouteCutoverState,
session_id: u64,
local_addr: SocketAddr,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
handle_via_direct_with_shared_and_conntrack(
client_reader,
client_writer,
success,
upstream_manager,
stats,
config,
buffer_pool,
rng,
route_rx,
route_snapshot,
session_id,
local_addr,
session_cancel,
shared,
ConntrackClosePolicy::Publish,
)
.await
}
/// Runs Direct relay with explicit kernel-conntrack close publication policy.
pub(crate) async fn handle_via_direct_with_shared_and_conntrack<R, W>(
client_reader: CryptoReader<R>,
client_writer: CryptoWriter<W>,
success: HandshakeSuccess,
@@ -280,6 +324,7 @@ pub(crate) async fn handle_via_direct_with_shared<R, W>(
local_addr: SocketAddr,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
conntrack_close_policy: ConntrackClosePolicy,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
@@ -407,17 +452,19 @@ where
pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled),
);
let close_reason = classify_conntrack_close_reason(&relay_result);
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
src: success.peer,
dst: local_addr,
reason: close_reason,
});
if !matches!(
publish_result,
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
) {
stats.increment_conntrack_close_event_drop_total();
if conntrack_close_policy == ConntrackClosePolicy::Publish {
let close_reason = classify_conntrack_close_reason(&relay_result);
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
src: success.peer,
dst: local_addr,
reason: close_reason,
});
if !matches!(
publish_result,
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
) {
stats.increment_conntrack_close_event_drop_total();
}
}
relay_result
+2 -1
View File
@@ -20,7 +20,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tracing::{debug, info, trace, warn};
use zeroize::{Zeroize, Zeroizing};
use crate::config::{ProxyConfig, UnknownSniAction};
use crate::config::{ProxyConfig, UnknownSniAction, WebSecretMode};
use crate::crypto::{AesCtr, SecureRandom, sha256};
use crate::error::{HandshakeResult, ProxyError};
use crate::protocol::constants::*;
@@ -58,6 +58,7 @@ pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState};
#[cfg(test)]
pub use self::mtproto::handle_mtproto_handshake;
pub use self::mtproto::handle_mtproto_handshake_with_shared;
pub(crate) use self::mtproto::handle_mtproto_handshake_for_web_user;
#[allow(unused_imports)]
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
pub use self::session::HandshakeSuccess;
+70 -1
View File
@@ -11,6 +11,12 @@ pub(super) struct MtprotoCandidateValidation {
pub(super) encryptor: AesCtr,
}
#[derive(Clone, Copy)]
pub(super) enum MtprotoModePolicy {
Configured,
Web(WebSecretMode),
}
pub(super) fn sni_hint_hash(sni: &str) -> u64 {
let mut hasher = DefaultHasher::new();
for byte in sni.bytes() {
@@ -146,6 +152,7 @@ pub(super) fn validate_mtproto_secret_candidate(
secret: &[u8; ACCESS_SECRET_BYTES],
config: &ProxyConfig,
is_tls: bool,
mode_policy: MtprotoModePolicy,
) -> Option<MtprotoCandidateValidation> {
let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
dec_key_input.extend_from_slice(dec_prekey);
@@ -163,7 +170,7 @@ pub(super) fn validate_mtproto_secret_candidate(
decrypted[PROTO_TAG_POS + 3],
];
let proto_tag = ProtoTag::from_bytes(tag_bytes)?;
if !mode_enabled_for_proto(config, proto_tag, is_tls) {
if !mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, mode_policy) {
return None;
}
@@ -267,6 +274,28 @@ pub(super) fn mode_enabled_for_proto(
proto_tag: ProtoTag,
is_tls: bool,
) -> bool {
mode_enabled_for_proto_with_policy(
config,
proto_tag,
is_tls,
MtprotoModePolicy::Configured,
)
}
fn mode_enabled_for_proto_with_policy(
config: &ProxyConfig,
proto_tag: ProtoTag,
is_tls: bool,
policy: MtprotoModePolicy,
) -> bool {
if let MtprotoModePolicy::Web(secret_mode) = policy {
return match secret_mode {
WebSecretMode::Plain => {
matches!(proto_tag, ProtoTag::Intermediate | ProtoTag::Abridged)
}
WebSecretMode::Dd => matches!(proto_tag, ProtoTag::Secure),
};
}
match proto_tag {
ProtoTag::Secure => {
if is_tls {
@@ -279,6 +308,46 @@ pub(super) fn mode_enabled_for_proto(
}
}
#[cfg(test)]
mod web_mode_tests {
use super::*;
#[test]
fn web_secret_mode_isolates_inner_protocol_tags() {
let config = ProxyConfig::default();
assert!(mode_enabled_for_proto_with_policy(
&config,
ProtoTag::Abridged,
false,
MtprotoModePolicy::Web(WebSecretMode::Plain),
));
assert!(mode_enabled_for_proto_with_policy(
&config,
ProtoTag::Intermediate,
false,
MtprotoModePolicy::Web(WebSecretMode::Plain),
));
assert!(!mode_enabled_for_proto_with_policy(
&config,
ProtoTag::Secure,
false,
MtprotoModePolicy::Web(WebSecretMode::Plain),
));
assert!(mode_enabled_for_proto_with_policy(
&config,
ProtoTag::Secure,
false,
MtprotoModePolicy::Web(WebSecretMode::Dd),
));
assert!(!mode_enabled_for_proto_with_policy(
&config,
ProtoTag::Intermediate,
false,
MtprotoModePolicy::Web(WebSecretMode::Dd),
));
}
}
pub(super) fn decode_user_secrets_in(
shared: &ProxySharedState,
config: &ProxyConfig,
+63 -10
View File
@@ -1,6 +1,6 @@
use super::*;
/// Handle MTProto obfuscation handshake
/// Handles an MTProto obfuscation handshake with isolated test state.
#[cfg(test)]
pub async fn handle_mtproto_handshake<R, W>(
handshake: &[u8; HANDSHAKE_LEN],
@@ -26,11 +26,14 @@ where
replay_checker,
is_tls,
preferred_user,
None,
MtprotoModePolicy::Configured,
shared.as_ref(),
)
.await
}
/// Handles an MTProto obfuscation handshake with process-shared defenses.
pub async fn handle_mtproto_handshake_with_shared<R, W>(
handshake: &[u8; HANDSHAKE_LEN],
reader: R,
@@ -55,6 +58,40 @@ where
replay_checker,
is_tls,
preferred_user,
None,
MtprotoModePolicy::Configured,
shared,
)
.await
}
/// Authenticates one WEB logical stream against exactly one user and secret mode.
pub(crate) async fn handle_mtproto_handshake_for_web_user<R, W>(
handshake: &[u8; HANDSHAKE_LEN],
reader: R,
writer: W,
peer: SocketAddr,
config: &ProxyConfig,
replay_checker: &ReplayChecker,
exact_user: &str,
secret_mode: WebSecretMode,
shared: &ProxySharedState,
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
where
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
handle_mtproto_handshake_impl(
handshake,
reader,
writer,
peer,
config,
replay_checker,
false,
None,
Some(exact_user),
MtprotoModePolicy::Web(secret_mode),
shared,
)
.await
@@ -69,6 +106,8 @@ async fn handle_mtproto_handshake_impl<R, W>(
replay_checker: &ReplayChecker,
is_tls: bool,
preferred_user: Option<&str>,
exact_user: Option<&str>,
mode_policy: MtprotoModePolicy,
shared: &ProxySharedState,
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
where
@@ -113,8 +152,11 @@ where
let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip());
let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip());
let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user));
let has_hint =
sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some();
let exact_user_id = exact_user.and_then(|user| snapshot.user_id_by_name(user));
let has_hint = sticky_ip_hint.is_some()
|| sticky_prefix_hint.is_some()
|| preferred_user_id.is_some()
|| exact_user_id.is_some();
let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now());
let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint);
@@ -145,6 +187,7 @@ where
&entry.secret,
config,
is_tls,
mode_policy,
) {
matched_user = entry.user.clone();
matched_user_id = Some($user_id);
@@ -159,20 +202,20 @@ where
}};
}
let mut matched = false;
if let Some(user_id) = sticky_ip_hint {
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 {
matched = try_user_id!(user_id);
}
if !matched && let Some(user_id) = preferred_user_id {
if exact_user.is_none() && !matched && let Some(user_id) = preferred_user_id {
matched = try_user_id!(user_id);
}
if !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);
}
if !matched && !budget_exhausted {
if exact_user.is_none() && !matched && !budget_exhausted {
let ring = &shared.handshake.recent_user_ring;
if !ring.is_empty() {
let next_seq = shared
@@ -197,7 +240,7 @@ where
}
}
if !matched && !budget_exhausted {
if exact_user.is_none() && !matched && !budget_exhausted {
for idx in 0..snapshot.entries().len() {
let Some(user_id) = u32::try_from(idx).ok() else {
break;
@@ -317,7 +360,16 @@ where
success,
));
} else {
let decoded_users = decode_user_secrets_in(shared, config, preferred_user);
let decoded_users = match exact_user {
Some(user) => config
.access
.users
.get(user)
.and_then(|secret| decode_user_secret(shared, user, secret))
.map(|secret| vec![(user.to_string(), secret)])
.unwrap_or_default(),
None => decode_user_secrets_in(shared, config, preferred_user),
};
let mut validation_checks = 0usize;
for (user, secret) in decoded_users {
@@ -337,6 +389,7 @@ where
&secret_arr,
config,
is_tls,
mode_policy,
) else {
continue;
};
+45 -2
View File
@@ -26,7 +26,8 @@ use crate::proxy::route_mode::{
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
};
use crate::proxy::shared_state::{
ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState,
ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason,
ProxySharedState,
};
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
use crate::stats::{
@@ -44,7 +45,7 @@ mod session;
pub(crate) use self::desync::DesyncDedupRotationState;
pub(crate) use self::idle::{RelayIdleCandidateRegistry, note_global_relay_pressure};
pub(crate) use self::session::handle_via_middle_proxy;
pub(crate) use self::session::handle_via_middle_proxy_with_conntrack;
use self::c2me::{
C2MeCommand, acquire_c2me_payload_permit, c2me_queued_permit_budget, enqueue_c2me_command_in,
@@ -91,6 +92,47 @@ pub(crate) use self::idle::{
set_relay_pressure_state_for_testing,
};
/// Runs Middle-End relay for a kernel-backed TCP client tuple.
pub(crate) async fn handle_via_middle_proxy<R, W>(
crypto_reader: CryptoReader<R>,
crypto_writer: CryptoWriter<W>,
success: HandshakeSuccess,
me_pool: Arc<MePool>,
stats: Arc<Stats>,
config: Arc<ProxyConfig>,
buffer_pool: Arc<BufferPool>,
local_addr: SocketAddr,
rng: Arc<SecureRandom>,
route_rx: watch::Receiver<RouteCutoverState>,
route_snapshot: RouteCutoverState,
session_id: u64,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
handle_via_middle_proxy_with_conntrack(
crypto_reader,
crypto_writer,
success,
me_pool,
stats,
config,
buffer_pool,
local_addr,
rng,
route_rx,
route_snapshot,
session_id,
session_cancel,
shared,
ConntrackClosePolicy::Publish,
)
.await
}
const DESYNC_DEDUP_WINDOW: Duration = Duration::from_secs(60);
const DESYNC_DEDUP_MAX_ENTRIES: usize = 65_536;
const DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL: Duration = Duration::from_millis(1000);
@@ -98,6 +140,7 @@ const DESYNC_ERROR_CLASS: &str = "frame_too_large_crypto_desync";
const C2ME_CHANNEL_CAPACITY_FALLBACK: usize = 128;
const C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS: usize = 64;
const C2ME_SENDER_FAIRNESS_BUDGET: usize = 32;
const C2ME_QUEUED_BYTE_PERMIT_UNIT: usize = 16 * 1024;
const C2ME_QUEUED_PERMITS_PER_SLOT: usize = 4;
const RELAY_IDLE_IO_POLL_MAX: Duration = Duration::from_secs(1);
+18 -14
View File
@@ -1,6 +1,7 @@
use super::*;
pub(crate) async fn handle_via_middle_proxy<R, W>(
/// Runs Middle-End relay with explicit kernel-conntrack close publication policy.
pub(crate) async fn handle_via_middle_proxy_with_conntrack<R, W>(
mut crypto_reader: CryptoReader<R>,
crypto_writer: CryptoWriter<W>,
success: HandshakeSuccess,
@@ -15,6 +16,7 @@ pub(crate) async fn handle_via_middle_proxy<R, W>(
session_id: u64,
session_cancel: CancellationToken,
shared: Arc<ProxySharedState>,
conntrack_close_policy: ConntrackClosePolicy,
) -> Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
@@ -78,7 +80,7 @@ where
return Err(ProxyError::RouteSwitched);
}
// Per-user ad_tag from access.user_ad_tags; fallback to general.ad_tag (hot-reloadable)
// Prefer the hot-reloadable per-user ad tag over the global fallback.
let user_tag: Option<Vec<u8>> = config
.access
.user_ad_tags
@@ -785,7 +787,7 @@ where
}
};
// When client closes, but ME channel stopped as unregistered - it isnt error
// A client-initiated close can unregister the ME channel before its writer exits.
if client_closed && matches!(writer_result, Err(ProxyError::MiddleConnectionLost)) {
writer_result = Ok(());
}
@@ -808,17 +810,19 @@ where
"ME relay cleanup"
);
let close_reason = classify_conntrack_close_reason(&result);
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
src: peer,
dst: local_addr,
reason: close_reason,
});
if !matches!(
publish_result,
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
) {
stats.increment_conntrack_close_event_drop_total();
if conntrack_close_policy == ConntrackClosePolicy::Publish {
let close_reason = classify_conntrack_close_reason(&result);
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
src: peer,
dst: local_addr,
reason: close_reason,
});
if !matches!(
publish_result,
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
) {
stats.increment_conntrack_close_event_drop_total();
}
}
clear_relay_idle_candidate_in(shared.as_ref(), conn_id);
+2
View File
@@ -59,6 +59,8 @@
)]
pub mod adaptive_buffers;
// Shared authenticated admission and relay orchestration for TCP and WEB streams.
pub(crate) mod authenticated;
pub mod client;
// Process-wide Direct relay copy-buffer ownership and pressure policy.
pub(crate) mod direct_buffer_budget;
+9
View File
@@ -41,6 +41,15 @@ pub(crate) enum ConntrackClosePublishResult {
QueueClosed,
}
/// Controls whether a relay tuple maps to a real kernel conntrack entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConntrackClosePolicy {
/// Publish closure for a tuple backed by an accepted kernel TCP flow.
Publish,
/// Suppress closure for a virtual transport tuple with no kernel flow.
Suppress,
}
pub(crate) struct HandshakeSharedState {
pub(crate) auth_probe: DashMap<IpAddr, AuthProbeState>,
pub(crate) auth_probe_saturation: Mutex<Option<AuthProbeSaturationState>>,
+3
View File
@@ -260,6 +260,7 @@ mod tests {
fn listener(ip: IpAddr, port: Option<u16>, synlimit: SynLimitMode) -> ListenerConfig {
ListenerConfig {
ip,
transport: crate::config::ListenerTransport::Mtproxy,
port,
client_mss: None,
synlimit,
@@ -275,6 +276,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
+311
View File
@@ -0,0 +1,311 @@
use base64::Engine as _;
use crate::config::WebCarrier;
use crate::crypto::SecureRandom;
/// Browser security policy for the transient Telegram Desktop bridge page.
pub(crate) const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), web-share=(), xr-spatial-tracking=()";
/// Fully rendered bridge response and its per-response script policy.
pub(crate) struct BridgePage {
/// Complete transient HTML document.
pub(crate) body: String,
/// Nonce-bound policy that authorizes only the embedded bridge script.
pub(crate) content_security_policy: String,
}
/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce.
pub(crate) fn render(
host: &str,
bootstrap: &str,
batch_limit: usize,
queue_limit: usize,
queue_items: usize,
carrier: WebCarrier,
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
rng.fill(&mut nonce);
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
let body = DOCUMENT
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
.replace("__BOOTSTRAP__", bootstrap)
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
.replace("__QUEUE_ITEMS__", &queue_items.to_string())
.replace("__CARRIER__", carrier.as_str());
BridgePage {
body,
content_security_policy: format!(
"default-src 'none'; base-uri 'none'; child-src 'none'; connect-src 'self' wss://{host}; font-src 'none'; form-action 'none'; frame-ancestors http://127.0.0.1:*; frame-src 'none'; img-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'nonce-{nonce}'; style-src 'none'; worker-src 'none'; sandbox allow-same-origin allow-scripts"
),
}
}
const DOCUMENT: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Connection</title>
</head>
<body>
<script nonce="__NONCE__">
(()=>{
'use strict';
const relayOrigin='https://__HOST__',bootstrap='__BOOTSTRAP__',carrier='__CARRIER__';
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
history.replaceState(null,'',location.pathname);
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false;
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
const options=(method,token,body,headers,signal,keepalive)=>({
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
});
function reserve(data,lane){
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes||queuedItems>=queueItemLimit)return false;
if(lane&&(data.byteLength>laneQueueLimit-lane.bytes||lane.items>=laneItemLimit))return false;
queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
}
function release(bytes,items,lane){queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}}
function frameBound(value,maxFrames,maxBytes){
const view=new DataView(value);let offset=0,frames=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
const size=view.getUint32(offset+4),end=offset+8+size;
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
frames++;offset=end;
}
if(!frames)throw new Error('empty frame batch');
return {frames,bytes:offset};
}
function splitFrames(value){
const view=new DataView(value),result=[];let offset=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8||result.length>=4096)throw new Error('invalid frame batch');
const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
const size=view.getUint32(offset+4),end=offset+8+size;
if((type===2&&!size)||size>1048576||end>value.byteLength)throw new Error('invalid frame');
result.push({type,id,data:offset===0&&end===value.byteLength?value:value.slice(offset,end)});offset=end;
}
if(!result.length)throw new Error('empty frame batch');return result;
}
function joinPending(values,lane){
let total=0,count=0,frames=0;
while(count<values.length){
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
if(count===0&&!whole){
const head=new Uint8Array(values[0],0,bound.bytes).slice();
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
return {body:head.buffer,total:bound.bytes,count:1};
}
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
total+=values[count].byteLength;frames+=bound.frames;count++;
}
const joined=new Uint8Array(total);let offset=0;
for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
return {body:joined.buffer,total,count};
}
function retryAfterMs(response){
const value=Number(response.headers.get('Retry-After'));
return Number.isFinite(value)&&value>=0?Math.min(value*1000,30000):0;
}
async function request(path,makeOptions){
let delay=250,attempt=0;const deadline=Date.now()+90000;
while(true){
const requestOptions=makeOptions(),controller=new AbortController(),external=requestOptions.signal;
const abort=()=>controller.abort();if(external)external.addEventListener('abort',abort,{once:true});
requestOptions.signal=controller.signal;const timer=setTimeout(abort,90000);
let serviceUnavailable=false,wait=0;
try{
const response=await fetch(relayOrigin+path,requestOptions);
if(response.status!==503)return response;
serviceUnavailable=true;wait=retryAfterMs(response);await response.arrayBuffer();
}catch(error){
if(closed||(external&&external.aborted))throw error;
if(++attempt===9)throw new Error('carrier retry limit reached');
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
if(serviceUnavailable&&Date.now()>=deadline)throw new Error('carrier retry limit reached');
status('reconnecting');await pause(wait||(delay+Math.floor(Math.random()*Math.max(1,delay/4))));
if(!serviceUnavailable)delay=Math.min(delay*2,5000);
}
}
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
async function createSession(first){
try{
status('connecting');
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session rejected');
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(closed){deleteSession();return}
const welcome=await response.arrayBuffer();
const welcomeBytes=new Uint8Array(welcome);
if(welcomeBytes.length!==8||welcomeBytes[0]!==17||welcomeBytes.slice(1).some(value=>value!==0))throw new Error('invalid welcome');
port.postMessage(welcome,[welcome]);status('connected');
if(carrier==='https-lanes')ensureLane(0);
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
if(carrier==='https')poll();else pollLane(lanes.get(0));
}catch(error){fail()}
}
function queueCarrier(data){
try{if(carrier==='https')queueUp(data);else for(const value of splitFrames(data))queueLane(value)}catch(error){fail()}
}
function queueUp(data){if(!reserve(data,null)){fail();return}upPending.push(data);runUp()}
async function runUp(){
if(upRunning)return;upRunning=true;
try{
while(!closed&&sessionToken&&upPending.length){
const batch=joinPending(upPending,null),sequence=String(upSequence);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence}));
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
release(batch.total,batch.count,null);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++;
}
}catch(error){fail()}
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
}
async function poll(){
while(!closed&&sessionToken){
try{
pollController=new AbortController();
const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
if(response.status===204){status('connected');continue}
if(response.status!==200)throw new Error('downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
if(!next||!data.byteLength)throw new Error('invalid downlink response');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
}catch(error){if(!closed)fail();return}
}
}
function ensureLane(id){
let lane=lanes.get(id);
if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,polling:false,controller:null};lanes.set(id,lane)}
return lane;
}
function rememberLaneClosed(id){
if(!id||closedLanes.has(id))return;
if(closedLaneOrder.length===closedLaneLimit)closedLanes.delete(closedLaneOrder.shift());
closedLanes.add(id);closedLaneOrder.push(id);
}
function finishLane(lane){
if(lanes.get(lane.id)!==lane)return;
if(lane.bytes||lane.items)release(lane.bytes,lane.items,lane);
lane.pending.length=0;lanes.delete(lane.id);rememberLaneClosed(lane.id);
}
function queueLane(value){
let lane=lanes.get(value.id);
if(!lane&&(value.type===2||value.type===3||value.type===4))return;
if(!lane&&closedLanes.has(value.id))throw new Error('closed lane was reused');
if(!lane&&value.type!==1)throw new Error('lane did not begin with OPEN');
lane=lane||ensureLane(value.id);
if(!reserve(value.data,lane)){fail();return}
lane.pending.push(value.data);runLaneUp(lane);
}
async function runLaneUp(lane){
if(lane.running)return;lane.running=true;
try{
while(!closed&&sessionToken&&lane.pending.length){
const batch=joinPending(lane.pending,lane),sequence=String(lane.sequence),laneID=String(lane.id);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID}));
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected');
release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});lane.sequence++;
if(!lane.polling)pollLane(lane);
}
}catch(error){fail()}
finally{lane.running=false;if(!closed&&sessionToken&&lane.pending.length)runLaneUp(lane)}
}
async function pollLane(lane){
if(!lane||lane.polling)return;lane.polling=true;
try{
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
if(response.status===204){
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane);return}
status('connected');continue;
}
if(response.status!==200)throw new Error('lane downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
if(!next||!data.byteLength||splitFrames(data).some(value=>value.id!==lane.id))throw new Error('invalid lane downlink response');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
}
}catch(error){if(!closed)fail()}
finally{lane.polling=false;lane.controller=null}
}
function deleteSession(){
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
}
function close(notifyServer){
if(closed)return;closed=true;if(pollController)pollController.abort();
for(const lane of lanes.values())if(lane.controller)lane.controller.abort();
if(notifyServer)deleteSession();pending.length=0;upPending.length=0;
for(const lane of lanes.values())lane.pending.length=0;lanes.clear();queuedBytes=0;queuedItems=0;if(port)port.close();
}
function activatePort(nextPort){
initialized=true;port=nextPort;
port.onmessage=message=>{
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;createSession(message.data)}
else if(!sessionToken){if(!reserve(message.data,null)){fail();return}pending.push(message.data)}
else queueCarrier(message.data);
}else if(message.data&&message.data.t==='close')close(true);
};
port.start();status('connecting');
}
addEventListener('message',event=>{
if(initialized||event.source!==parent||event.data===null||typeof event.data!=='object')return;
const keys=Object.keys(event.data).sort();
if(keys.length!==2||keys[0]!=='t'||keys[1]!=='v'||event.data.t!=='tproxy-init'||event.data.v!==1||event.ports.length!==1)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;
activatePort(event.ports[0]);
});
const androidBridge=globalThis.TelegramWebProxy;
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
if(value instanceof ArrayBuffer){for(const item of splitFrames(value))androidBridge.postMessage(item.data)}else androidBridge.postMessage(JSON.stringify(value));
}};
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}));
}
addEventListener('pagehide',()=>close(true),{once:true});
})();
</script>
</body>
</html>
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendered_page_contains_no_template_markers_or_capability() {
let page = render(
"proxy.example.com",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::HttpsLanes,
&SecureRandom::new(),
);
assert!(!page.body.contains("__"));
assert!(!page.body.contains("bridge="));
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("carrier='https-lanes'"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page
.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*"));
}
}
+274
View File
@@ -0,0 +1,274 @@
use bytes::{BufMut, Bytes, BytesMut};
use crate::config::WebLimitsConfig;
/// Fixed WEB frame header size.
pub(crate) const HEADER_BYTES: usize = 8;
/// Largest stream identifier representable by the WEB frame header.
pub(crate) const MAX_STREAM_ID: u32 = 0x00ff_ffff;
/// Initial bidirectional stream credit.
pub(crate) const INITIAL_STREAM_WINDOW: u32 = 4 * 1024 * 1024;
/// Maximum data chunk emitted by the server.
pub(crate) const DATA_CHUNK_BYTES: usize = 64 * 1024;
/// WEB frame type codes shared with Telegram Desktop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum FrameType {
/// Opens a logical MTProxy stream.
Open = 0x01,
/// Carries logical-stream payload bytes.
Data = 0x02,
/// Closes a logical stream.
Close = 0x03,
/// Returns consumed flow-control credit.
Window = 0x04,
/// Requests an application-level liveness response.
Ping = 0x05,
/// Answers application-level liveness traffic.
Pong = 0x06,
/// Starts one WEB carrier session.
Hello = 0x10,
/// Confirms WEB carrier session creation.
Welcome = 0x11,
/// Terminates a WEB carrier session.
Bye = 0x1f,
}
impl FrameType {
fn parse(value: u8) -> Option<Self> {
match value {
0x01 => Some(Self::Open),
0x02 => Some(Self::Data),
0x03 => Some(Self::Close),
0x04 => Some(Self::Window),
0x05 => Some(Self::Ping),
0x06 => Some(Self::Pong),
0x10 => Some(Self::Hello),
0x11 => Some(Self::Welcome),
0x1f => Some(Self::Bye),
_ => None,
}
}
}
/// One parsed frame borrowing its payload from the HTTP request body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Frame<'a> {
/// Parsed frame type.
pub(crate) frame_type: FrameType,
/// Logical 24-bit stream identifier.
pub(crate) stream_id: u32,
/// Borrowed frame payload.
pub(crate) payload: &'a [u8],
}
/// Protocol parse or shape failure.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FrameError {
/// A carrier body contained no frame.
EmptyBatch,
/// A carrier body exceeded the configured frame count.
TooManyFrames,
/// A frame header or payload was truncated.
Incomplete,
/// A frame payload exceeded its configured ceiling.
PayloadLimit,
/// The frame type code is not defined.
UnknownType,
/// A known frame violated direction-specific grammar.
InvalidShape,
}
/// Parses and validates all frame boundaries without copying payloads.
pub(crate) fn parse_all<'a>(
input: &'a [u8],
limits: &WebLimitsConfig,
) -> std::result::Result<Vec<Frame<'a>>, FrameError> {
if input.is_empty() {
return Err(FrameError::EmptyBatch);
}
let mut remaining = input;
let mut frames = Vec::with_capacity(remaining.len().div_ceil(HEADER_BYTES).min(16));
while !remaining.is_empty() {
if frames.len() >= limits.max_frames_per_body {
return Err(FrameError::TooManyFrames);
}
if remaining.len() < HEADER_BYTES {
return Err(FrameError::Incomplete);
}
let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?;
let stream_id = u32::from(remaining[1]) << 16
| u32::from(remaining[2]) << 8
| u32::from(remaining[3]);
let payload_len = u32::from_be_bytes([
remaining[4],
remaining[5],
remaining[6],
remaining[7],
]) as usize;
if payload_len > limits.max_frame_payload_bytes {
return Err(FrameError::PayloadLimit);
}
let frame_len = HEADER_BYTES
.checked_add(payload_len)
.ok_or(FrameError::PayloadLimit)?;
if frame_len > remaining.len() {
return Err(FrameError::Incomplete);
}
frames.push(Frame {
frame_type,
stream_id,
payload: &remaining[HEADER_BYTES..frame_len],
});
remaining = &remaining[frame_len..];
}
Ok(frames)
}
/// Enforces the client-to-server frame grammar.
pub(crate) fn validate_client_shape(frame: Frame<'_>) -> std::result::Result<(), FrameError> {
if frame.stream_id == 0 {
return if frame.frame_type == FrameType::Pong && frame.payload.len() <= 64 {
Ok(())
} else {
Err(FrameError::InvalidShape)
};
}
match frame.frame_type {
FrameType::Open | FrameType::Close if frame.payload.is_empty() => Ok(()),
FrameType::Data if !frame.payload.is_empty() => Ok(()),
FrameType::Window => window_amount(frame.payload).map(|_| ()),
_ => Err(FrameError::InvalidShape),
}
}
/// Validates the exact first-session HELLO body.
pub(crate) fn validate_hello(input: &[u8], limits: &WebLimitsConfig) -> bool {
let Ok(frames) = parse_all(input, limits) else {
return false;
};
frames.len() == 1
&& frames[0].frame_type == FrameType::Hello
&& frames[0].stream_id == 0
&& frames[0].payload == [1]
}
/// Encodes one complete WEB frame.
pub(crate) fn encode(frame_type: FrameType, stream_id: u32, payload: &[u8]) -> Bytes {
let mut output = BytesMut::with_capacity(HEADER_BYTES + payload.len());
output.put_u8(frame_type as u8);
output.put_u8((stream_id >> 16) as u8);
output.put_u8((stream_id >> 8) as u8);
output.put_u8(stream_id as u8);
output.put_u32(payload.len() as u32);
output.extend_from_slice(payload);
output.freeze()
}
/// Decodes a non-zero WINDOW delta.
pub(crate) fn window_amount(payload: &[u8]) -> std::result::Result<u32, FrameError> {
let bytes: [u8; 4] = payload.try_into().map_err(|_| FrameError::InvalidShape)?;
let amount = u32::from_be_bytes(bytes);
(amount != 0)
.then_some(amount)
.ok_or(FrameError::InvalidShape)
}
/// Encodes a WINDOW delta payload.
pub(crate) fn window_payload(amount: u32) -> [u8; 4] {
amount.to_be_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_and_welcome_match_reference_bytes() {
let limits = WebLimitsConfig::default();
let hello = encode(FrameType::Hello, 0, &[1]);
assert_eq!(hello.as_ref(), &hex::decode("100000000000000101").unwrap());
assert!(validate_hello(&hello, &limits));
assert_eq!(
encode(FrameType::Welcome, 0, &[]).as_ref(),
&hex::decode("1100000000000000").unwrap()
);
}
#[test]
fn parser_rejects_excessive_payload_before_slicing() {
let limits = WebLimitsConfig {
max_frame_payload_bytes: 4,
..WebLimitsConfig::default()
};
let frame = encode(FrameType::Data, 1, &[0; 5]);
assert_eq!(parse_all(&frame, &limits), Err(FrameError::PayloadLimit));
}
#[test]
fn client_shape_rejects_control_types_on_stream_zero() {
let frame = Frame {
frame_type: FrameType::Ping,
stream_id: 0,
payload: &[],
};
assert_eq!(validate_client_shape(frame), Err(FrameError::InvalidShape));
}
#[test]
fn stream_frames_match_client_reference_vectors() {
assert_eq!(
encode(FrameType::Open, 17, &[]).as_ref(),
&hex::decode("0100001100000000").unwrap()
);
assert_eq!(
encode(FrameType::Data, 17, b"round trip").as_ref(),
&hex::decode("020000110000000a726f756e642074726970").unwrap()
);
assert_eq!(
encode(FrameType::Window, 17, &10u32.to_be_bytes()).as_ref(),
&hex::decode("04000011000000040000000a").unwrap()
);
assert_eq!(
encode(FrameType::Open, 0x00ff_ffff, &[]).as_ref(),
&hex::decode("01ffffff00000000").unwrap()
);
}
#[test]
fn parser_rejects_empty_truncated_and_excessive_batches() {
let mut limits = WebLimitsConfig::default();
assert_eq!(parse_all(&[], &limits), Err(FrameError::EmptyBatch));
assert_eq!(
parse_all(&hex::decode("0200000100000001").unwrap(), &limits),
Err(FrameError::Incomplete)
);
limits.max_frames_per_body = 1;
let mut body = encode(FrameType::Pong, 0, &[]).to_vec();
body.extend_from_slice(&encode(FrameType::Pong, 0, &[]));
assert_eq!(parse_all(&body, &limits), Err(FrameError::TooManyFrames));
}
#[test]
fn client_shape_rejects_empty_data_and_zero_window() {
let empty_data = Frame {
frame_type: FrameType::Data,
stream_id: 1,
payload: &[],
};
let zero_window = Frame {
frame_type: FrameType::Window,
stream_id: 1,
payload: &[0; 4],
};
assert_eq!(
validate_client_shape(empty_data),
Err(FrameError::InvalidShape)
);
assert_eq!(
validate_client_shape(zero_window),
Err(FrameError::InvalidShape)
);
}
}
+550
View File
@@ -0,0 +1,550 @@
use std::convert::Infallible;
use std::error::Error;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::{TokioIo, TokioTimer};
use ipnetwork::IpNetwork;
use parking_lot::Mutex;
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
use crate::config::{WebCarrier, WebClientIpSource, WebRuntimeVhost};
use crate::web::bridge;
use crate::web::frame::{self, FrameType};
use crate::web::manager::{ManagerError, WebProcessRuntime};
// Response-body activity keeps connection idle accounting lifecycle-correct.
mod activity;
// Body collection retains allocation permits through request processing.
mod body;
// Decoy routing and upstream proxying are isolated from carrier authentication.
mod decoy;
// Canonical request parsing rejects ambiguous credentials before routing.
mod request;
#[cfg(test)]
mod tests;
use decoy::serve_decoy;
use activity::{ActivityBody, RequestActivity};
use body::{CollectBodyError, CollectedBody, collect_body};
use request::{
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
canonical_u64_header, client_ip, match_profile,
};
type BoxError = Box<dyn Error + Send + Sync>;
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
type HttpResponse = Response<HttpBody>;
const CREATE_BODY_LIMIT: usize = 64;
const TRANSPORT_PATHS: [&str; 3] = ["/api/v1/session", "/api/v1/up", "/api/v1/down"];
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
pub(crate) async fn serve_connection(
stream: TcpStream,
peer: SocketAddr,
client_ip_source: WebClientIpSource,
trusted_proxy_cidrs: Arc<[IpNetwork]>,
runtime: Arc<WebProcessRuntime>,
cancellation: CancellationToken,
connection_permit: tokio::sync::OwnedSemaphorePermit,
) {
let config = runtime.active_generation().config();
let max_header_bytes = config.web.limits.max_header_bytes;
let header_timeout = Duration::from_secs(config.web.timeouts.header_secs);
let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs);
let last_activity = Arc::new(Mutex::new(Instant::now()));
let service_last_activity = Arc::clone(&last_activity);
let service = service_fn(move |request| {
let runtime = Arc::clone(&runtime);
let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs);
let last_activity = Arc::clone(&service_last_activity);
let client_ip_source = client_ip_source;
async move {
let activity = RequestActivity::begin(last_activity);
let response = if let Some(_handler_permit) = runtime.try_http_handler() {
handle_request(
request,
peer,
client_ip_source,
&trusted_proxy_cidrs,
runtime,
)
.await
} else {
service_unavailable()
};
let response = response.map(|body| {
ActivityBody::new(body, activity)
.boxed_unsync()
});
Ok::<_, Infallible>(response)
}
});
let connection = http1::Builder::new()
.timer(TokioTimer::new())
.header_read_timeout(header_timeout)
.max_buf_size(max_header_bytes)
.keep_alive(true)
.serve_connection(TokioIo::new(stream), service);
tokio::pin!(connection);
let mut idle_check = tokio::time::interval((idle_timeout / 2).max(Duration::from_secs(1)));
idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = cancellation.cancelled() => break,
_ = &mut connection => break,
_ = idle_check.tick() => {
if Instant::now().saturating_duration_since(*last_activity.lock())
>= idle_timeout
{
break;
}
}
}
}
drop(connection_permit);
}
async fn handle_request(
request: Request<Incoming>,
peer: SocketAddr,
client_ip_source: WebClientIpSource,
trusted_proxy_cidrs: &[IpNetwork],
runtime: Arc<WebProcessRuntime>,
) -> HttpResponse {
let generation = runtime.active_generation();
let config = generation.config();
let Some(web_runtime) = config.web.runtime.as_ref() else {
return generic_not_found();
};
let Some(host) = canonical_request_host(&request) else {
return generic_not_found();
};
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
return generic_not_found();
};
let path = request.uri().path();
if TRANSPORT_PATHS.contains(&path) {
return handle_api(
request,
peer,
client_ip_source,
trusted_proxy_cidrs,
runtime,
vhost,
)
.await;
}
if path == "/" && matches!(*request.method(), Method::GET | Method::HEAD) {
return handle_root(
request,
peer,
client_ip_source,
trusted_proxy_cidrs,
runtime,
vhost,
)
.await;
}
serve_decoy(request, vhost, false, &runtime).await
}
async fn handle_root(
mut request: Request<Incoming>,
peer: SocketAddr,
client_ip_source: WebClientIpSource,
trusted_proxy_cidrs: &[IpNetwork],
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
) -> HttpResponse {
let (candidate, canonical) = bridge_candidate(request.uri().query());
let profile = match_profile(&vhost, &candidate);
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
return serve_decoy(request, vhost, false, &runtime).await;
};
let Some(client_ip) = client_ip(
&request,
peer,
client_ip_source,
trusted_proxy_cidrs,
) else {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
let carrier = profile.carrier;
let Ok(bootstrap) = runtime.issue_bootstrap(profile, client_ip) else {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
let generation = runtime.active_generation();
let page = bridge::render(
&vhost.host,
&bootstrap,
generation.config().web.limits.carrier_batch_bytes,
generation.config().web.limits.pending_bytes_per_session,
generation.config().web.limits.pending_items_per_session,
carrier,
&generation.rng,
);
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
insert_header(
&mut response,
header::CONTENT_SECURITY_POLICY,
&page.content_security_policy,
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(
HeaderName::from_static("x-dns-prefetch-control"),
HeaderValue::from_static("off"),
);
insert_header(
&mut response,
HeaderName::from_static("permissions-policy"),
bridge::PERMISSIONS_POLICY,
);
response
}
async fn handle_api(
request: Request<Incoming>,
peer: SocketAddr,
client_ip_source: WebClientIpSource,
trusted_proxy_cidrs: &[IpNetwork],
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
) -> HttpResponse {
if request.uri().query().is_some() || request.headers().contains_key(header::COOKIE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some(client_ip) = client_ip(
&request,
peer,
client_ip_source,
trusted_proxy_cidrs,
) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(token_hash) = bearer_token_hash(&request) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
match request.uri().path() {
"/api/v1/session" => {
handle_session(request, runtime, vhost, token_hash, client_ip).await
}
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
_ => serve_decoy(request, vhost, true, &runtime).await,
}
}
async fn handle_session(
request: Request<Incoming>,
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
token_hash: crate::web::manager::TokenHash,
client_ip: IpAddr,
) -> HttpResponse {
if request.headers().contains_key("x-lane-id") {
return serve_decoy(request, vhost, true, &runtime).await;
}
if request.method() == Method::DELETE {
if request.headers().contains_key(header::CONTENT_TYPE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let CollectedBody {
request,
body,
_body_budget,
} = match collect_body(request, &runtime, 1, true).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
return serve_decoy(request, vhost, true, &runtime).await;
}
};
if !body.is_empty() || runtime.close_token(token_hash, &vhost.host).is_err() {
return serve_decoy(request, vhost, true, &runtime).await;
}
return carrier_empty(StatusCode::NO_CONTENT);
}
if request.method() != Method::POST || !binary_content_type(&request) {
return serve_decoy(request, vhost, true, &runtime).await;
}
if !runtime.has_bootstrap(token_hash, &vhost.host) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let CollectedBody {
request,
body,
_body_budget,
} = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
return serve_decoy(request, vhost, true, &runtime).await;
}
};
match runtime.create_session(token_hash, &vhost.host, client_ip, &body) {
Ok(result) => {
let welcome = frame::encode(FrameType::Welcome, 0, &[]);
let mut response = full_response(StatusCode::OK, welcome);
carrier_headers(&mut response);
insert_header(
&mut response,
HeaderName::from_static("x-session-token"),
&result.token,
);
response.headers_mut().insert(
HeaderName::from_static("x-carrier-mode"),
HeaderValue::from_static(result.carrier.as_str()),
);
response.headers_mut().insert(
HeaderName::from_static("x-down-cursor"),
HeaderValue::from_static("0"),
);
response
}
Err(ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent) => {
service_unavailable()
}
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
}
}
async fn handle_up(
request: Request<Incoming>,
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
token_hash: crate::web::manager::TokenHash,
) -> HttpResponse {
if request.method() != Method::POST || !binary_content_type(&request) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some(sequence) = canonical_u64_header(&request, "x-up-seq").filter(|value| *value != 0)
else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let limit = runtime.active_generation().config().web.limits.max_body_bytes;
let CollectedBody {
request,
body,
_body_budget,
} = match collect_body(request, &runtime, limit, false).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
return serve_decoy(request, vhost, true, &runtime).await;
}
};
let result = match lane_id {
Some(lane_id) => session.process_up_lane(lane_id, sequence, &body),
None => session.process_up(sequence, &body),
};
match result {
Ok(ack) => {
let mut response = carrier_empty(StatusCode::NO_CONTENT);
insert_header(
&mut response,
HeaderName::from_static("x-up-ack"),
&ack.to_string(),
);
response
}
Err(ManagerError::Backpressure | ManagerError::Concurrent | ManagerError::Limit) => {
service_unavailable()
}
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
}
}
async fn handle_down(
request: Request<Incoming>,
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
token_hash: crate::web::manager::TokenHash,
) -> HttpResponse {
if request.method() != Method::POST || request.headers().contains_key(header::CONTENT_TYPE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let CollectedBody {
request,
body,
_body_budget,
} = match collect_body(request, &runtime, 1, true).await {
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
return serve_decoy(request, vhost, true, &runtime).await;
}
};
if !body.is_empty() {
return serve_decoy(request, vhost, true, &runtime).await;
}
let _lane_poll = if lane_id.is_some() {
let Some(permit) = runtime.try_lane_poll() else {
return service_unavailable();
};
Some(permit)
} else {
None
};
let result = match lane_id {
Some(lane_id) => session.poll_down_lane(lane_id, cursor).await,
None => session.poll_down(cursor).await,
};
match result {
Ok(result) if result.body.is_empty() => {
let mut response = carrier_empty(StatusCode::NO_CONTENT);
insert_header(
&mut response,
HeaderName::from_static("x-down-cursor"),
&result.next_cursor.to_string(),
);
if result.lane_closed {
response.headers_mut().insert(
HeaderName::from_static("x-lane-closed"),
HeaderValue::from_static("1"),
);
}
response
}
Ok(result) => {
let mut response = full_response(StatusCode::OK, result.body);
carrier_headers(&mut response);
insert_header(
&mut response,
HeaderName::from_static("x-down-cursor"),
&result.next_cursor.to_string(),
);
response
}
Err(ManagerError::Concurrent | ManagerError::Backpressure | ManagerError::Limit) => {
service_unavailable()
}
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
}
}
fn carrier_lane<B>(request: &Request<B>, carrier: WebCarrier) -> Option<Option<u32>> {
match carrier {
WebCarrier::Https => (!request.headers().contains_key("x-lane-id")).then_some(None),
WebCarrier::HttpsLanes => canonical_u64_header(request, "x-lane-id")
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value <= frame::MAX_STREAM_ID)
.map(Some),
}
}
fn carrier_headers(response: &mut HttpResponse) {
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
fn carrier_empty(status: StatusCode) -> HttpResponse {
let mut response = empty_response(status);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn service_unavailable() -> HttpResponse {
let mut response = carrier_empty(StatusCode::SERVICE_UNAVAILABLE);
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
response
}
fn bad_gateway() -> HttpResponse {
full_response(
StatusCode::BAD_GATEWAY,
Bytes::from_static(b"site unavailable\n"),
)
}
fn generic_not_found() -> HttpResponse {
full_response(
StatusCode::NOT_FOUND,
Bytes::from_static(b"not found\n"),
)
}
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
let length = body.len();
let body = Full::new(body)
.map_err(|never| -> BoxError { match never {} })
.boxed_unsync();
let mut response = Response::new(body);
*response.status_mut() = status;
insert_header(
&mut response,
header::CONTENT_LENGTH,
&length.to_string(),
);
response
}
fn empty_response(status: StatusCode) -> HttpResponse {
full_response(status, Bytes::new())
}
fn insert_header(response: &mut HttpResponse, name: HeaderName, value: &str) {
if let Ok(value) = HeaderValue::from_str(value) {
response.headers_mut().insert(name, value);
}
}
fn strip_query<B>(request: &mut Request<B>) {
if request.uri().query().is_some()
&& let Ok(uri) = request.uri().path().parse()
{
*request.uri_mut() = uri;
}
}
+69
View File
@@ -0,0 +1,69 @@
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;
use bytes::Bytes;
use hyper::body::{Body, Frame, SizeHint};
use parking_lot::Mutex;
use super::{BoxError, HttpBody};
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
pub(super) struct RequestActivity {
last_activity: Arc<Mutex<Instant>>,
}
impl RequestActivity {
/// Starts activity accounting for one HTTP request.
pub(super) fn begin(last_activity: Arc<Mutex<Instant>>) -> Self {
*last_activity.lock() = Instant::now();
Self { last_activity }
}
}
impl Drop for RequestActivity {
fn drop(&mut self) {
*self.last_activity.lock() = Instant::now();
}
}
/// Response body wrapper that refreshes activity while downstream data progresses.
pub(super) struct ActivityBody {
inner: HttpBody,
activity: RequestActivity,
}
impl ActivityBody {
/// Binds one response body to its request activity guard.
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
Self {
inner,
activity,
}
}
}
impl Body for ActivityBody {
type Data = Bytes;
type Error = BoxError;
fn poll_frame(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let result = Pin::new(&mut self.inner).poll_frame(context);
if result.is_ready() {
*self.activity.last_activity.lock() = Instant::now();
}
result
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Limited};
use hyper::body::{Body as _, Incoming};
use hyper::Request;
use crate::web::manager::WebProcessRuntime;
/// Collected carrier request retaining its process-wide body reservation.
pub(super) struct CollectedBody {
/// Request head reconstructed without the consumed network body.
pub(super) request: Request<Empty<Bytes>>,
/// Fully collected bounded carrier payload.
pub(super) body: Bytes,
/// Byte-budget reservation held through request processing.
pub(super) _body_budget: tokio::sync::OwnedSemaphorePermit,
}
// Keep rejected requests inline to avoid attacker-controlled allocations on invalid bodies.
#[allow(clippy::large_enum_variant)]
/// Body collection failure with sanitized request context when decoy routing is safe.
pub(super) enum CollectBodyError {
/// The body shape, size, or deadline failed after retaining the request head.
Invalid(Request<Empty<Bytes>>),
/// Process-wide body reader or byte capacity is temporarily exhausted.
Limit,
}
/// Collects one bounded carrier body under reader, byte, and deadline ownership.
pub(super) async fn collect_body(
request: Request<Incoming>,
runtime: &WebProcessRuntime,
limit: usize,
allow_empty: bool,
) -> Result<CollectedBody, CollectBodyError> {
let exceeds_limit = request.body().size_hint().lower() > limit as u64
|| request
.body()
.size_hint()
.upper()
.is_some_and(|upper| upper > limit as u64);
let (parts, body) = request.into_parts();
if exceeds_limit {
return Err(CollectBodyError::Invalid(Request::from_parts(
parts,
Empty::new(),
)));
}
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
return Err(CollectBodyError::Limit);
};
let body_timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.body_secs,
);
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
Ok(Ok(body)) => body.to_bytes(),
_ => {
return Err(CollectBodyError::Invalid(Request::from_parts(
parts,
Empty::new(),
)));
}
};
drop(reader_budget);
let request = Request::from_parts(parts, Empty::new());
if !allow_empty && body.is_empty() {
return Err(CollectBodyError::Invalid(request));
}
Ok(CollectedBody {
request,
body,
_body_budget: body_budget,
})
}
+284
View File
@@ -0,0 +1,284 @@
use std::error::Error;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::{Method, Request, StatusCode, Uri};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
use super::{
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
insert_header,
};
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
use crate::web::manager::WebProcessRuntime;
/// Serves the configured ordinary site after optionally removing carrier material.
pub(super) async fn serve_decoy<B>(
mut request: Request<B>,
vhost: Arc<WebRuntimeVhost>,
sanitize_transport: bool,
runtime: &WebProcessRuntime,
) -> HttpResponse
where
B: hyper::body::Body<Data = Bytes> + Send + 'static,
B::Error: Error + Send + Sync + 'static,
{
if sanitize_transport {
sanitize_transport_request(&mut request);
}
let (parts, body) = request.into_parts();
let body = if sanitize_transport {
Empty::<Bytes>::new()
.map_err(|never| -> BoxError { match never {} })
.boxed_unsync()
} else {
body.map_err(|error| -> BoxError { Box::new(error) })
.boxed_unsync()
};
let request = Request::from_parts(parts, body);
match &vhost.decoy {
WebRuntimeDecoy::StaticDirectory(site) => serve_static(request, site),
WebRuntimeDecoy::HttpUpstream { addr, authority } => {
proxy_to_upstream(
request,
*addr,
authority,
Duration::from_secs(vhost.decoy_header_secs),
runtime,
)
.await
}
}
}
fn serve_static<B>(request: Request<B>, site: &crate::config::WebStaticSite) -> HttpResponse {
if !matches!(*request.method(), Method::GET | Method::HEAD) {
return static_entry(request, site, None, StatusCode::NOT_FOUND);
}
let path = request.uri().path();
let resolved = resolve_static_path(path, site);
let status = if resolved.is_some() {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
};
static_entry(request, site, resolved, status)
}
fn static_entry<B>(
request: Request<B>,
site: &crate::config::WebStaticSite,
route: Option<&str>,
status: StatusCode,
) -> HttpResponse {
let fallback = format!("/{}", site.index);
let not_found = site.assets.contains_key("/404.html").then_some("/404.html");
let route = route.or(not_found).unwrap_or(&fallback);
let Some(asset) = site.assets.get(route) else {
return generic_not_found();
};
let not_modified = status == StatusCode::OK
&& request
.headers()
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
== Some(asset.etag.as_str());
let response_body = if request.method() == Method::HEAD || not_modified {
Bytes::new()
} else {
asset.body.clone()
};
let mut response = full_response(
if not_modified {
StatusCode::NOT_MODIFIED
} else {
status
},
response_body,
);
insert_header(&mut response, header::CONTENT_TYPE, asset.content_type);
insert_header(&mut response, header::ETAG, &asset.etag);
insert_header(
&mut response,
header::CONTENT_LENGTH,
&asset.body.len().to_string(),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
if status.is_client_error() || request.uri().query().is_some() {
HeaderValue::from_static("no-store")
} else {
HeaderValue::from_static("public, max-age=300")
},
);
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; style-src 'self'; img-src 'self'; worker-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"),
);
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
response
}
fn resolve_static_path<'a>(
path: &str,
site: &'a crate::config::WebStaticSite,
) -> Option<&'a str> {
if !path.starts_with('/')
|| path.contains('\\')
|| path.contains("//")
|| path.split('/').any(|part| matches!(part, "." | ".."))
{
return None;
}
let root;
let route = if path == "/" {
root = format!("/{}", site.index);
root.as_str()
} else {
path
};
if site.assets.contains_key(route) {
return site.assets.get_key_value(route).map(|(key, _)| key.as_str());
}
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
return Some("/favicon.svg");
}
if !route.rsplit('/').next().unwrap_or_default().contains('.') {
let html = format!("{route}.html");
return site
.assets
.get_key_value(&html)
.map(|(key, _)| key.as_str());
}
None
}
async fn proxy_to_upstream(
mut request: Request<HttpBody>,
addr: SocketAddr,
authority: &str,
header_timeout: Duration,
runtime: &WebProcessRuntime,
) -> HttpResponse {
remove_hop_by_hop(request.headers_mut());
if let Ok(host) = HeaderValue::from_str(authority) {
request.headers_mut().insert(header::HOST, host);
}
let path_and_query = request
.uri()
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let Ok(uri) = path_and_query.parse::<Uri>() else {
return bad_gateway();
};
*request.uri_mut() = uri;
let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => stream,
_ => return bad_gateway(),
};
let max_header_bytes = runtime
.active_generation()
.config()
.web
.limits
.max_header_bytes;
let mut builder = hyper::client::conn::http1::Builder::new();
builder.max_buf_size(max_header_bytes);
let (mut sender, connection) = match tokio::time::timeout(
header_timeout,
builder.handshake(TokioIo::new(stream)),
)
.await
{
Ok(Ok(parts)) => parts,
_ => return bad_gateway(),
};
runtime.spawn_auxiliary(async move {
let _ = connection.await;
});
let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await
{
Ok(Ok(response)) => response,
_ => return bad_gateway(),
};
remove_hop_by_hop(response.headers_mut());
response.map(|body| {
body.map_err(|error| -> BoxError { Box::new(error) })
.boxed_unsync()
})
}
fn sanitize_transport_request<B>(request: &mut Request<B>) {
for name in [
header::AUTHORIZATION,
header::CONTENT_LENGTH,
header::CONTENT_TYPE,
header::UPGRADE,
HeaderName::from_static("sec-websocket-key"),
HeaderName::from_static("sec-websocket-protocol"),
HeaderName::from_static("sec-websocket-version"),
HeaderName::from_static("x-down-cursor"),
HeaderName::from_static("x-lane-id"),
HeaderName::from_static("x-up-seq"),
] {
request.headers_mut().remove(name);
}
request
.headers_mut()
.insert(header::CONNECTION, HeaderValue::from_static("close"));
}
fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) {
let nominated = headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.filter_map(|value| HeaderName::from_bytes(value.trim().as_bytes()).ok())
.collect::<Vec<_>>();
for name in nominated {
headers.remove(name);
}
for name in [
header::CONNECTION,
header::PROXY_AUTHENTICATE,
header::PROXY_AUTHORIZATION,
header::TE,
header::TRAILER,
header::TRANSFER_ENCODING,
header::UPGRADE,
HeaderName::from_static("keep-alive"),
HeaderName::from_static("proxy-connection"),
] {
headers.remove(name);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_resolver_rejects_rewritten_paths() {
let site = crate::config::WebStaticSite {
assets: std::collections::BTreeMap::new(),
index: "index.html".to_string(),
};
assert!(resolve_static_path("/../index.html", &site).is_none());
assert!(resolve_static_path("//index.html", &site).is_none());
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use base64::Engine as _;
use hyper::header;
use hyper::Request;
use ipnetwork::IpNetwork;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::config::{
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
};
use crate::web::manager::TokenHash;
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
let values = request.headers().get_all(header::HOST);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() {
return None;
}
let authority = value.parse::<hyper::http::uri::Authority>().ok()?;
if authority.port_u16().is_some_and(|port| port != 443) {
return None;
}
let host = value.strip_suffix(":443").unwrap_or(value);
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase())
{
return None;
}
Some(host)
}
/// Accepts one canonical forwarded client address from an explicitly trusted peer.
pub(super) fn client_ip<B>(
request: &Request<B>,
peer: SocketAddr,
source: WebClientIpSource,
trusted_proxy_cidrs: &[IpNetwork],
) -> Option<IpAddr> {
if !trusted_proxy_cidrs
.iter()
.any(|network| network.contains(peer.ip()))
{
return None;
}
let header_name = match source {
WebClientIpSource::XForwardedFor => "x-forwarded-for",
};
let values = request.headers().get_all(header_name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some()
|| value.is_empty()
|| value.trim() != value
|| value.contains(',')
{
return None;
}
let ip = value.parse::<IpAddr>().ok()?;
(ip.to_string() == value).then_some(ip)
}
/// Decodes an exact canonical bridge query without allocating credential strings.
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
let mut candidate = [0u8; 32];
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
return (candidate, false);
};
if value.len() != 43 {
return (candidate, false);
}
let mut decoded = [0u8; 32];
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode_slice(value, &mut decoded)
else {
return (candidate, false);
};
let mut canonical = [0u8; 43];
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode_slice(decoded, &mut canonical)
else {
return (candidate, false);
};
if decoded_len != decoded.len()
|| encoded_len != canonical.len()
|| !bool::from(canonical.ct_eq(value.as_bytes()))
{
return (candidate, false);
}
candidate = decoded;
(candidate, true)
}
/// Matches a capability in constant time across every profile of one virtual host.
pub(super) fn match_profile(
vhost: &WebRuntimeVhost,
candidate: &[u8; 32],
) -> Option<Arc<WebRuntimeProfile>> {
let mut matched = None;
for profile in &vhost.profiles {
if bool::from(profile.capability.ct_eq(candidate)) {
matched = Some(Arc::clone(profile));
}
}
matched
}
/// Validates and hashes one canonical bearer credential for map lookup.
pub(super) fn bearer_token_hash<B>(request: &Request<B>) -> Option<TokenHash> {
let values = request.headers().get_all(header::AUTHORIZATION);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1
{
return None;
}
let token = value.strip_prefix("Bearer ")?;
if token.len() != 43 {
return None;
}
let mut decoded = [0u8; 32];
let decoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode_slice(token, &mut decoded)
.ok()?;
let mut canonical = [0u8; 43];
let encoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode_slice(decoded, &mut canonical)
.ok()?;
(decoded_len == decoded.len()
&& encoded_len == canonical.len()
&& bool::from(canonical.ct_eq(token.as_bytes())))
.then(|| Sha256::digest(decoded).into())
}
/// Checks the exact carrier media type without accepting duplicate headers.
pub(super) fn binary_content_type<B>(request: &Request<B>) -> bool {
let values = request.headers().get_all(header::CONTENT_TYPE);
let mut values = values.iter();
let value = values.next().and_then(|value| value.to_str().ok());
values.next().is_none()
&& value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream"))
}
/// Parses one canonical unsigned decimal carrier sequence header.
pub(super) fn canonical_u64_header<B>(
request: &Request<B>,
name: &'static str,
) -> Option<u64> {
let values = request.headers().get_all(name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some()
|| value.is_empty()
|| value.starts_with('+')
|| (value.len() > 1 && value.starts_with('0'))
{
return None;
}
let parsed = value.parse::<u64>().ok()?;
(parsed.to_string() == value).then_some(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_bridge_query_rejects_aliases() {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
}
#[test]
fn host_and_forwarded_identity_require_canonical_single_values() {
let request = Request::builder()
.header(header::HOST, "proxy.example.com:443")
.header("x-forwarded-for", "192.0.2.10")
.body(())
.unwrap();
assert_eq!(
canonical_request_host(&request),
Some("proxy.example.com")
);
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
assert_eq!(
client_ip(
&request,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
),
Some("192.0.2.10".parse().unwrap())
);
let uppercase = Request::builder()
.header(header::HOST, "Proxy.Example.com")
.body(())
.unwrap();
assert!(canonical_request_host(&uppercase).is_none());
let appended = Request::builder()
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
.body(())
.unwrap();
assert!(
client_ip(
&appended,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
)
.is_none()
);
}
#[test]
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
let request = Request::builder()
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("x-up-seq", "17")
.body(())
.unwrap();
assert_eq!(
bearer_token_hash(&request),
Some(Sha256::digest([1u8; 32]).into())
);
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
let leading_zero = Request::builder()
.header("x-up-seq", "017")
.body(())
.unwrap();
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
}
}
+287
View File
@@ -0,0 +1,287 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use base64::Engine as _;
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use super::serve_connection;
use crate::config::{
ProxyConfig, WebCarrier, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
};
use crate::maestro::generation::test_runtime_generation;
use crate::web::frame::{self, FrameType};
use crate::web::manager::WebProcessRuntime;
fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier,
capability,
max_sessions: 4,
max_streams: 16,
max_streams_per_session: 4,
});
let mut assets = BTreeMap::new();
assets.insert(
"/index.html".to_string(),
WebStaticAsset {
body: Bytes::from_static(b"<!doctype html><title>decoy</title>"),
content_type: "text/html; charset=utf-8",
etag: "\"test\"".to_string(),
},
);
let site = Arc::new(WebStaticSite {
assets,
index: "index.html".to_string(),
});
let vhost = Arc::new(WebRuntimeVhost {
host: "proxy.example.com".to_string(),
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
decoy_header_secs: 1,
profiles: vec![Arc::clone(&profile)],
});
let mut vhosts = BTreeMap::new();
vhosts.insert("proxy.example.com".to_string(), vhost);
vhosts.insert(
"other.example.com".to_string(),
Arc::new(WebRuntimeVhost {
host: "other.example.com".to_string(),
decoy: WebRuntimeDecoy::StaticDirectory(site),
decoy_header_secs: 1,
profiles: Vec::new(),
}),
);
let mut config = ProxyConfig::default();
config.web.enabled = true;
config.web.carrier = carrier;
config.web.limits.max_bootstraps_per_ip = 1;
config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts,
profiles: vec![profile],
}));
config
}
async fn request(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
request: Vec<u8>,
) -> Vec<u8> {
let addr = listener.local_addr().unwrap();
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
let (server, peer) = accepted.unwrap();
let mut client = client.unwrap();
let permit = runtime.try_http_connection().unwrap();
let task = tokio::spawn(serve_connection(
server,
peer,
WebClientIpSource::XForwardedFor,
Arc::from(["127.0.0.1/32".parse().unwrap()]),
Arc::clone(runtime),
CancellationToken::new(),
permit,
));
client.write_all(&request).await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
task.await.unwrap();
response
}
fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
let separator = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.unwrap();
(&response[..separator], &response[separator + 4..])
}
fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
std::str::from_utf8(headers)
.unwrap()
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim()))
.unwrap()
}
#[tokio::test]
async fn https_carrier_bootstraps_and_closes_one_session() {
let capability = [7u8; 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 wrong_family = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let wrong_family_response = request(&listener, &runtime, wrong_family).await;
let (_, wrong_family_body) = split_response(&wrong_family_response);
assert!(!wrong_family_body
.windows(11)
.any(|value| value == b"bootstrap='"));
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (root_headers, root_body) = split_response(&root_response);
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
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();
assert_eq!(bootstrap.len(), 43);
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut wrong_host = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: other.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();
wrong_host.extend_from_slice(&hello);
let wrong_host_response = request(&listener, &runtime, wrong_host).await;
assert!(wrong_host_response.starts_with(b"HTTP/1.1 404"));
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();
let create_retry = create.clone();
create.extend_from_slice(&hello);
let mut create_retry = create_retry;
create_retry.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
let (create_headers, create_body) = split_response(&create_response);
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https");
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
let session = response_header(create_headers, "x-session-token");
assert_eq!(session.len(), 43);
let replacement = test_runtime_generation(
2,
runtime_config(capability, WebCarrier::HttpsLanes),
);
active_runtime.store(Arc::clone(&replacement));
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let retry_response = request(&listener, &runtime, create_retry).await;
let (retry_headers, retry_body) = split_response(&retry_response);
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(retry_headers, "x-session-token"), session);
assert_eq!(response_header(retry_headers, "x-carrier-mode"), "https");
assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[]));
let next_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 next_root_response = request(&listener, &runtime, next_root).await;
let (_, next_root_body) = split_response(&next_root_response);
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
assert!(next_root_body
.windows(21)
.any(|value| value == b"carrier='https-lanes'"));
let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let close_retry = close.clone();
let close_response = request(&listener, &runtime, close).await;
assert!(close_response.starts_with(b"HTTP/1.1 204"));
let close_retry_response = request(&listener, &runtime, close_retry).await;
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
replacement.stop_sessions().await;
replacement.stop_background_tasks().await;
}
#[tokio::test]
async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
let capability = [9u8; 32];
let generation = test_runtime_generation(
1,
runtime_config(capability, WebCarrier::HttpsLanes),
);
let 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: 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();
assert!(root_body.contains("carrier='https-lanes'"));
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;
let (create_headers, _) = split_response(&create_response);
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https-lanes");
let session = response_header(create_headers, "x-session-token").to_string();
let pong = frame::encode(FrameType::Pong, 0, &[]);
let mut uplink = 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: 1\r\nX-Lane-ID: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
uplink.extend_from_slice(&pong);
let uplink_response = request(&listener, &runtime, uplink).await;
let (uplink_headers, _) = split_response(&uplink_response);
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
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",
pong.len()
)
.into_bytes();
missing_lane.extend_from_slice(&pong);
let missing_lane_response = request(&listener, &runtime, missing_lane).await;
assert!(!missing_lane_response.starts_with(b"HTTP/1.1 204"));
let mut aliased_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\nX-Lane-ID: 00\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
aliased_lane.extend_from_slice(&pong);
let aliased_lane_response = request(&listener, &runtime, aliased_lane).await;
assert!(!aliased_lane_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+548
View File
@@ -0,0 +1,548 @@
use std::future::Future;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use zeroize::Zeroizing;
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile};
use crate::maestro::generation::RuntimeGeneration;
use crate::web::frame;
use crate::web::session::WebSession;
// Credential maps, quotas, and token-bucket helpers remain private to the manager.
mod state;
// Stream admission and synthetic tuple ownership are process-scoped.
mod admission;
// Shutdown and expiry work remain outside request-path coordination.
mod lifecycle;
use state::{
Bootstrap, ManagerState, allow_rate, control_item_reserve, decrement_map,
evict_oldest_unused_bootstrap, matching_profile, new_unique_token, profile_key,
remove_expired_locked,
};
const TOKEN_BYTES: usize = 32;
const CLEANUP_INTERVAL: Duration = Duration::from_secs(1);
/// Stable hash key used for bootstrap and session credentials.
pub(crate) type TokenHash = [u8; TOKEN_BYTES];
/// Stable non-allocating key used for per-profile quotas.
pub(crate) type ProfileKey = [u8; TOKEN_BYTES];
/// WEB manager operation failure category.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ManagerError {
/// Credential, hostname, or ownership validation failed.
Authentication,
/// Bounded queue capacity is temporarily unavailable.
Backpressure,
/// A configured admission or rate ceiling was reached.
Limit,
/// Carrier framing or sequencing violated the protocol.
Protocol,
/// The operation conflicts with another in-flight operation.
Concurrent,
/// The process or session has stopped accepting work.
Closed,
}
/// Successful idempotent session creation result.
pub(crate) struct CreateResult {
/// Opaque bearer token for the created or replayed session.
pub(crate) token: String,
/// Carrier frozen into the created or replayed session.
pub(crate) carrier: WebCarrier,
}
/// Process-owned bounded WEB credential, session, and memory coordinator.
pub(crate) struct WebProcessRuntime {
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
limits: WebLimitsConfig,
state: Mutex<ManagerState>,
http_connections: Arc<Semaphore>,
http_handlers: Arc<Semaphore>,
lane_polls: Arc<Semaphore>,
body_readers: Arc<Semaphore>,
body_bytes: Arc<Semaphore>,
stream_handshakes: Arc<Semaphore>,
budget_notify: Arc<Notify>,
budget_saturated: AtomicBool,
shutdown: CancellationToken,
tasks: TaskTracker,
sessions_created: AtomicU64,
sessions_closed: AtomicU64,
streams_opened: AtomicU64,
streams_rejected: AtomicU64,
bytes_up: AtomicU64,
bytes_down: AtomicU64,
limit_hits: AtomicU64,
}
impl WebProcessRuntime {
/// Starts one process-scoped manager using immutable allocation ceilings.
pub(crate) fn start(
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
) -> Arc<Self> {
let limits = active_runtime.load().config().web.limits.clone();
let runtime = Arc::new(Self {
active_runtime,
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)),
lane_polls: Arc::new(Semaphore::new((limits.max_http_handlers / 2).max(1))),
body_readers: Arc::new(Semaphore::new(limits.max_body_readers)),
body_bytes: Arc::new(Semaphore::new(limits.max_body_bytes_global)),
stream_handshakes: Arc::new(Semaphore::new(limits.max_stream_handshakes)),
limits,
state: Mutex::new(ManagerState::default()),
budget_notify: Arc::new(Notify::new()),
budget_saturated: AtomicBool::new(false),
shutdown: CancellationToken::new(),
tasks: TaskTracker::new(),
sessions_created: AtomicU64::new(0),
sessions_closed: AtomicU64::new(0),
streams_opened: AtomicU64::new(0),
streams_rejected: AtomicU64::new(0),
bytes_up: AtomicU64::new(0),
bytes_down: AtomicU64::new(0),
limit_hits: AtomicU64::new(0),
});
let weak = Arc::downgrade(&runtime);
let shutdown = runtime.shutdown.clone();
runtime.tasks.spawn(async move {
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
_ = interval.tick() => {
let Some(runtime) = weak.upgrade() else {
break;
};
runtime.cleanup();
}
}
}
});
runtime
}
/// Loads the currently active generation without retaining older generations.
pub(crate) fn active_generation(&self) -> Arc<RuntimeGeneration> {
self.active_runtime.load_full()
}
/// Reserves one accepted HTTP connection.
pub(crate) fn try_http_connection(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.http_connections).try_acquire_owned().ok();
if permit.is_none() {
self.record_limit_hit();
}
permit
}
/// Reserves one concurrently executing HTTP request handler.
pub(crate) fn try_http_handler(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.http_handlers).try_acquire_owned().ok();
if permit.is_none() {
self.record_limit_hit();
}
permit
}
/// Reserves one parked lane poll without exhausting all HTTP handlers.
pub(crate) fn try_lane_poll(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.lane_polls).try_acquire_owned().ok();
if permit.is_none() {
self.record_limit_hit();
}
permit
}
/// Reserves one logical stream in the inner MTProxy handshake phase.
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.stream_handshakes)
.try_acquire_owned()
.ok();
if permit.is_none() {
self.record_stream_rejected();
}
permit
}
/// Spawns one process-owned auxiliary task with shutdown cancellation.
pub(crate) fn spawn_auxiliary<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let shutdown = self.shutdown.clone();
self.tasks.spawn(async move {
tokio::select! {
_ = shutdown.cancelled() => {}
_ = future => {}
}
});
}
/// Reserves one body reader and its declared bounded body allocation.
pub(crate) fn try_body_budget(
&self,
bytes: usize,
) -> Option<(OwnedSemaphorePermit, OwnedSemaphorePermit)> {
let Some(bytes) = u32::try_from(bytes).ok() else {
self.record_limit_hit();
return None;
};
let Some(reader) = Arc::clone(&self.body_readers).try_acquire_owned().ok() else {
self.record_limit_hit();
return None;
};
let Some(body) = Arc::clone(&self.body_bytes)
.try_acquire_many_owned(bytes)
.ok()
else {
self.record_limit_hit();
return None;
};
Some((reader, body))
}
/// Issues a one-use bootstrap credential for the active generation.
pub(crate) fn issue_bootstrap(
&self,
profile: Arc<WebRuntimeProfile>,
client_ip: IpAddr,
) -> std::result::Result<String, ManagerError> {
let generation = self.active_generation();
let config = generation.config();
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &profile))
.ok_or(ManagerError::Authentication)?;
if !config.web.enabled
|| profile.public_addr.is_ipv4() != client_ip.is_ipv4()
|| !generation.proxy_shared.is_user_enabled(&profile.user)
{
return Err(ManagerError::Closed);
}
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
if state.closed
|| state.bootstraps_per_ip.get(&client_ip).copied().unwrap_or(0)
>= self.limits.max_bootstraps_per_ip
|| !allow_rate(
&mut state.bootstrap_rate,
now,
self.limits.new_bootstraps_per_minute,
self.limits.new_bootstraps_burst,
)
{
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
}
if state.bootstraps.len() >= self.limits.max_bootstraps_global
&& !evict_oldest_unused_bootstrap(&mut state)
{
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
}
let Some((token, hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
state.bootstraps.insert(
hash,
Bootstrap {
generation_id: generation.id,
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
issued_at: now,
issuance_ip: client_ip,
profile,
body_digest: [0; TOKEN_BYTES],
session_token: Zeroizing::new(String::new()),
session: None,
used: false,
},
);
*state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1;
Ok(token)
}
/// Checks whether a bootstrap token is live before reading a request body.
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
let generation_id = self.active_runtime.load().id;
let now = Instant::now();
let state = self.state.lock();
state.bootstraps.get(&hash).is_some_and(|entry| {
entry.profile.host == host
&& now <= entry.expires_at
&& (entry.generation_id == generation_id
|| entry.used && entry.session.is_some())
})
}
/// Creates a session exactly once or replays the original successful result.
pub(crate) fn create_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
host: &str,
client_ip: IpAddr,
body: &[u8],
) -> std::result::Result<CreateResult, ManagerError> {
if !frame::validate_hello(body, &self.limits) {
return Err(ManagerError::Protocol);
}
let body_digest: TokenHash = Sha256::digest(body).into();
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
return Err(ManagerError::Authentication);
};
if entry.profile.host != host || now > entry.expires_at {
return Err(ManagerError::Authentication);
}
if entry.used {
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
if !digest_matches {
return Err(ManagerError::Authentication);
}
let session = entry
.session
.as_ref()
.ok_or(ManagerError::Authentication)?;
return Ok(CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
});
}
if entry.generation_id != generation.id {
return Err(ManagerError::Authentication);
}
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &entry.profile))
.filter(|profile| {
profile.public_addr.is_ipv4() == client_ip.is_ipv4()
&& generation.proxy_shared.is_user_enabled(&profile.user)
})
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if state.sessions.len() >= self.limits.max_sessions_global
|| state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
>= self.limits.max_sessions_per_ip
|| state
.sessions_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
>= profile.max_sessions
|| !allow_rate(
&mut state.session_rate,
now,
self.limits.new_sessions_per_minute,
self.limits.new_sessions_burst,
)
{
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
profile,
profile_key,
self.limits.clone(),
config.web.timeouts.clone(),
);
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.used = true;
entry.body_digest = body_digest;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
let issuance_ip = entry.issuance_ip;
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.sessions_created.fetch_add(1, Ordering::Relaxed);
Ok(CreateResult {
token: session_token,
carrier: session.carrier(),
})
}
/// Resolves an authenticated session token.
pub(crate) fn get_session(
&self,
hash: TokenHash,
host: &str,
) -> std::result::Result<Arc<WebSession>, ManagerError> {
self.state
.lock()
.sessions
.get(&hash)
.cloned()
.filter(|session| session.matches_host(host))
.ok_or(ManagerError::Authentication)
}
/// Closes a live token and accepts bounded tombstone retries.
pub(crate) fn close_token(
&self,
hash: TokenHash,
host: &str,
) -> std::result::Result<(), ManagerError> {
let state = self.state.lock();
let session = state
.sessions
.get(&hash)
.filter(|session| session.matches_host(host))
.cloned();
let closed = state
.closed_tokens
.get(&hash)
.is_some_and(|closed| closed.host == host);
drop(state);
if let Some(session) = session {
session.close();
return Ok(());
}
closed.then_some(()).ok_or(ManagerError::Authentication)
}
/// Reserves bounded process-wide queue capacity for data or control traffic.
pub(crate) fn try_reserve_pending(
&self,
bytes: usize,
items: usize,
control: bool,
downlink: bool,
) -> bool {
let mut state = self.state.lock();
let data_byte_limit = self
.limits
.pending_bytes_global
.saturating_sub(self.limits.control_bytes_global);
let control_item_reserve = control_item_reserve(&self.limits);
let data_item_limit = self
.limits
.pending_items_global
.saturating_sub(control_item_reserve);
if state.closed {
return false;
}
let fits = if control {
bytes <= self.limits.control_bytes_global
&& items <= control_item_reserve
&& state.pending_bytes
<= self.limits.pending_bytes_global.saturating_sub(bytes)
&& state.pending_items
<= self.limits.pending_items_global.saturating_sub(items)
&& state.pending_control_bytes
<= self.limits.control_bytes_global.saturating_sub(bytes)
&& state.pending_control_items
<= control_item_reserve.saturating_sub(items)
} else {
let data_bytes = state
.pending_bytes
.saturating_sub(state.pending_control_bytes);
let data_items = state
.pending_items
.saturating_sub(state.pending_control_items);
let (byte_limit, item_limit) = if downlink {
let uplink_bytes = self
.limits
.max_body_bytes
.saturating_add(
self.limits
.max_frames_per_body
.saturating_mul(crate::web::session::QUEUE_ITEM_COST),
);
(
data_byte_limit.saturating_sub(uplink_bytes),
data_item_limit.saturating_sub(self.limits.max_frames_per_body),
)
} else {
(data_byte_limit, data_item_limit)
};
bytes <= byte_limit
&& items <= item_limit
&& data_bytes <= byte_limit - bytes
&& data_items <= item_limit - items
};
if !fits {
self.budget_saturated.store(true, Ordering::Release);
self.record_limit_hit();
return false;
}
state.pending_bytes += bytes;
state.pending_items += items;
if control {
state.pending_control_bytes += bytes;
state.pending_control_items += items;
}
true
}
/// Releases process-wide queue capacity and wakes blocked relay writers.
pub(crate) fn release_pending(&self, bytes: usize, items: usize, control: bool) {
let mut state = self.state.lock();
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
state.pending_items = state.pending_items.saturating_sub(items);
if control {
state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes);
state.pending_control_items = state.pending_control_items.saturating_sub(items);
}
drop(state);
if self.budget_saturated.swap(false, Ordering::AcqRel) {
self.budget_notify.notify_waiters();
}
}
/// Returns the shared notification source for global queue capacity changes.
pub(crate) fn budget_notify(&self) -> Arc<Notify> {
Arc::clone(&self.budget_notify)
}
/// Accounts one successfully committed carrier uplink body.
pub(crate) fn record_up(&self, bytes: usize) {
self.bytes_up.fetch_add(bytes as u64, Ordering::Relaxed);
}
/// Accounts one emitted carrier downlink body.
pub(crate) fn record_down(&self, bytes: usize) {
self.bytes_down.fetch_add(bytes as u64, Ordering::Relaxed);
}
fn record_limit_hit(&self) {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
}
}
+130
View File
@@ -0,0 +1,130 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::Ordering;
use std::time::Instant;
use super::state::{
allocate_stream_port, allow_rate, decrement_map, release_stream_port,
};
use super::{ProfileKey, WebProcessRuntime};
impl WebProcessRuntime {
/// Reserves one process-wide and per-profile live logical-stream slot.
pub(crate) fn try_acquire_stream(
&self,
profile_key: ProfileKey,
max_streams: usize,
client_ip: IpAddr,
public_addr: SocketAddr,
) -> Option<u16> {
let now = Instant::now();
let mut state = self.state.lock();
if state.closed
|| state.streams_live >= self.limits.max_streams_global
|| state
.streams_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
>= max_streams
|| !allow_rate(
&mut state.stream_rate,
now,
self.limits.new_streams_per_minute,
self.limits.new_streams_burst,
)
{
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return None;
}
let Some(peer_port) = allocate_stream_port(&mut state, client_ip, public_addr) else {
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return None;
};
state.streams_live += 1;
*state
.streams_per_profile
.entry(profile_key)
.or_insert(0) += 1;
self.streams_opened.fetch_add(1, Ordering::Relaxed);
Some(peer_port)
}
/// Releases one live logical-stream slot after its relay task exits.
pub(crate) fn release_stream(
&self,
profile_key: ProfileKey,
client_ip: IpAddr,
public_addr: SocketAddr,
peer_port: u16,
) {
let mut state = self.state.lock();
if !release_stream_port(&mut state, client_ip, public_addr, peer_port) {
return;
}
state.streams_live = state.streams_live.saturating_sub(1);
decrement_map(&mut state.streams_per_profile, &profile_key);
}
/// Records a logical stream rejected outside manager quota acquisition.
pub(crate) fn record_stream_rejected(&self) {
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
self.record_limit_hit();
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arc_swap::ArcSwap;
use super::*;
use crate::config::ProxyConfig;
use crate::maestro::generation::test_runtime_generation;
use crate::web::session::QUEUE_ITEM_COST;
#[tokio::test]
async fn global_downlink_budget_preserves_one_maximum_uplink_batch() {
let generation = test_runtime_generation(1, ProxyConfig::default());
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation)));
let control_items = super::super::state::control_item_reserve(&runtime.limits);
let data_bytes = runtime
.limits
.pending_bytes_global
.saturating_sub(runtime.limits.control_bytes_global);
let data_items = runtime
.limits
.pending_items_global
.saturating_sub(control_items);
let uplink_bytes = runtime
.limits
.max_body_bytes
.saturating_add(runtime.limits.max_frames_per_body * QUEUE_ITEM_COST);
let downlink_bytes = data_bytes - uplink_bytes;
let downlink_items = data_items - runtime.limits.max_frames_per_body;
assert!(runtime.try_reserve_pending(
downlink_bytes,
downlink_items,
false,
true,
));
assert!(runtime.try_reserve_pending(
uplink_bytes,
runtime.limits.max_frames_per_body,
false,
false,
));
assert!(!runtime.try_reserve_pending(1, 1, false, true));
runtime.release_pending(downlink_bytes, downlink_items, false);
runtime.release_pending(
uplink_bytes,
runtime.limits.max_frames_per_body,
false,
);
runtime.shutdown().await;
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::net::IpAddr;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tracing::info;
use super::{ProfileKey, TokenHash, WebProcessRuntime};
use super::state::{
ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked,
};
impl WebProcessRuntime {
/// Removes one closed session and retains a bounded host-bound replay marker.
pub(crate) fn session_finished(
&self,
hash: TokenHash,
client_ip: IpAddr,
profile_key: ProfileKey,
profile_host: &str,
) {
let mut state = self.state.lock();
if state.sessions.remove(&hash).is_none() {
return;
}
decrement_map(&mut state.sessions_per_ip, &client_ip);
decrement_map(&mut state.sessions_per_profile, &profile_key);
let expiry = Instant::now()
+ Duration::from_secs(
self.active_runtime
.load()
.config()
.web
.timeouts
.bootstrap_lifetime_secs,
);
state.closed_tokens.insert(
hash,
ClosedToken {
expires_at: expiry,
host: profile_host.to_string(),
},
);
while state.closed_tokens.len() > self.limits.max_sessions_global.saturating_mul(16) {
let Some(oldest) = state
.closed_tokens
.iter()
.min_by_key(|(_, closed)| closed.expires_at)
.map(|(hash, _)| *hash)
else {
break;
};
state.closed_tokens.remove(&oldest);
}
let bootstrap_hashes = state
.bootstraps
.iter()
.filter_map(|(bootstrap_hash, bootstrap)| {
bootstrap
.session
.as_ref()
.is_some_and(|session| session.token_hash() == hash)
.then_some(*bootstrap_hash)
})
.collect::<Vec<_>>();
for bootstrap_hash in bootstrap_hashes {
remove_bootstrap_locked(&mut state, bootstrap_hash);
}
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
}
/// Stops issuance, closes all sessions, and joins bounded child work.
pub(crate) async fn shutdown(&self) {
self.shutdown.cancel();
let sessions = {
let mut state = self.state.lock();
state.closed = true;
state.bootstraps.clear();
state.bootstraps_per_ip.clear();
state.sessions.values().cloned().collect::<Vec<_>>()
};
for session in &sessions {
session.close();
}
let timeout_secs = self
.active_runtime
.load()
.config()
.web
.timeouts
.shutdown_secs;
let waits = async {
for session in sessions {
session.wait().await;
}
};
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await;
self.tasks.close();
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await;
let (sessions_live, streams_live, pending_bytes, pending_items) = {
let state = self.state.lock();
(
state.sessions.len(),
state.streams_live,
state.pending_bytes,
state.pending_items,
)
};
info!(
target: "telemt::web",
sessions_created = self.sessions_created.load(Ordering::Relaxed),
sessions_closed = self.sessions_closed.load(Ordering::Relaxed),
sessions_live,
streams_opened = self.streams_opened.load(Ordering::Relaxed),
streams_rejected = self.streams_rejected.load(Ordering::Relaxed),
streams_live,
pending_bytes,
pending_items,
bytes_up = self.bytes_up.load(Ordering::Relaxed),
bytes_down = self.bytes_down.load(Ordering::Relaxed),
limit_hits = self.limit_hits.load(Ordering::Relaxed),
"WEB runtime stopped"
);
}
/// Expires credentials and closes idle sessions without holding locks across callbacks.
pub(super) fn cleanup(&self) {
let generation_id = self.active_runtime.load().id;
let now = Instant::now();
let sessions = {
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let stale_bootstraps = state
.bootstraps
.iter()
.filter_map(|(hash, bootstrap)| {
(bootstrap.generation_id != generation_id && !bootstrap.used)
.then_some(*hash)
})
.collect::<Vec<_>>();
for hash in stale_bootstraps {
remove_bootstrap_locked(&mut state, hash);
}
state.sessions.values().cloned().collect::<Vec<_>>()
};
for session in sessions.into_iter().filter(|session| session.is_idle(now)) {
session.close();
}
}
}
+294
View File
@@ -0,0 +1,294 @@
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
use base64::Engine as _;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use super::{ProfileKey, TOKEN_BYTES, TokenHash};
use crate::config::{WebLimitsConfig, WebRuntimeConfig, WebRuntimeProfile};
use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::WebSession;
/// One issued bootstrap and optional idempotent session-creation replay state.
pub(super) struct Bootstrap {
/// Generation that issued the bootstrap.
pub(super) generation_id: u64,
/// Credential and replay-state expiry deadline.
pub(super) expires_at: Instant,
/// Stable ordering point used for bounded eviction.
pub(super) issued_at: Instant,
/// Forwarded client address that owns this credential.
pub(super) issuance_ip: IpAddr,
/// Immutable profile selected during capability validation.
pub(super) profile: Arc<WebRuntimeProfile>,
/// Digest of the accepted HELLO body for idempotent retry matching.
pub(super) body_digest: TokenHash,
/// Zeroizing copy returned only for an exact session-creation retry.
pub(super) session_token: Zeroizing<String>,
/// Created session retained while retry replay remains valid.
pub(super) session: Option<Arc<WebSession>>,
/// Distinguishes unused issuance quota from completed creation replay state.
pub(super) used: bool,
}
/// Bounded replay marker for one explicitly or naturally closed session token.
pub(super) struct ClosedToken {
/// Deadline after which the token hash may be forgotten.
pub(super) expires_at: Instant,
/// Canonical host that owned the session.
pub(super) host: String,
}
/// Token-bucket state for one process-wide creation class.
#[derive(Default)]
pub(super) struct RateState {
tokens: f64,
last: Option<Instant>,
}
struct StreamPortState {
active: HashSet<u16>,
next: u16,
}
/// Process-wide WEB registries and quota accounting protected by one short lock.
#[derive(Default)]
pub(super) struct ManagerState {
/// Bootstrap credentials indexed by their SHA-256 token hash.
pub(super) bootstraps: HashMap<TokenHash, Bootstrap>,
/// Unused bootstrap ownership counts by forwarded client address.
pub(super) bootstraps_per_ip: HashMap<IpAddr, usize>,
/// Live sessions indexed by bearer-token hash.
pub(super) sessions: HashMap<TokenHash, Arc<WebSession>>,
/// Recently closed token hashes retained for idempotent DELETE semantics.
pub(super) closed_tokens: HashMap<TokenHash, ClosedToken>,
/// Live session counts by forwarded client address.
pub(super) sessions_per_ip: HashMap<IpAddr, usize>,
/// Live session counts by stable profile key.
pub(super) sessions_per_profile: HashMap<ProfileKey, usize>,
/// Live relay-task counts by stable profile key.
pub(super) streams_per_profile: HashMap<ProfileKey, usize>,
/// Process-wide live relay-task count.
pub(super) streams_live: usize,
stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>,
/// Total process-wide queued byte reservation.
pub(super) pending_bytes: usize,
/// Total process-wide queued item reservation.
pub(super) pending_items: usize,
/// Portion of queued bytes charged to the control reserve.
pub(super) pending_control_bytes: usize,
/// Portion of queued items charged to the control reserve.
pub(super) pending_control_items: usize,
/// Bootstrap issuance rate limiter.
pub(super) bootstrap_rate: RateState,
/// Session creation rate limiter.
pub(super) session_rate: RateState,
/// Logical-stream creation rate limiter.
pub(super) stream_rate: RateState,
/// Process shutdown admission latch.
pub(super) closed: bool,
}
/// Generates one collision-checked credential and its stable hash key.
pub(super) fn new_unique_token(
generation: &RuntimeGeneration,
state: &ManagerState,
) -> Option<(String, TokenHash)> {
for _ in 0..8 {
let mut raw = [0u8; TOKEN_BYTES];
generation.rng.fill(&mut raw);
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let hash = Sha256::digest(raw).into();
if !state.bootstraps.contains_key(&hash)
&& !state.sessions.contains_key(&hash)
&& !state.closed_tokens.contains_key(&hash)
{
return Some((token, hash));
}
}
None
}
/// Returns the precomputed capability as the stable process profile key.
pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey {
profile.capability
}
/// Re-resolves an issued profile against the active generation without weakening identity.
pub(super) fn matching_profile(
runtime: &WebRuntimeConfig,
expected: &WebRuntimeProfile,
) -> Option<Arc<WebRuntimeProfile>> {
runtime
.profiles
.iter()
.find(|profile| {
profile.host == expected.host
&& profile.public_addr == expected.public_addr
&& profile.user == expected.user
&& profile.secret_mode == expected.secret_mode
&& profile.carrier == expected.carrier
&& profile.capability == expected.capability
})
.cloned()
}
/// Applies one token-bucket admission decision at a caller-supplied monotonic time.
pub(super) fn allow_rate(
state: &mut RateState,
now: Instant,
per_minute: u32,
burst: u32,
) -> bool {
let burst = f64::from(burst);
if let Some(last) = state.last {
let elapsed = now.saturating_duration_since(last).as_secs_f64();
state.tokens =
(state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
} else {
state.tokens = burst;
}
state.last = Some(now);
if state.tokens < 1.0 {
return false;
}
state.tokens -= 1.0;
true
}
/// Evicts the oldest unused bootstrap while preserving used retry state.
pub(super) fn evict_oldest_unused_bootstrap(state: &mut ManagerState) -> bool {
let Some(hash) = state
.bootstraps
.iter()
.filter(|(_, bootstrap)| !bootstrap.used)
.min_by_key(|(_, bootstrap)| bootstrap.issued_at)
.map(|(hash, _)| *hash)
else {
return false;
};
remove_bootstrap_locked(state, hash);
true
}
/// Removes expired bootstrap and closed-token entries while the manager lock is held.
pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) {
let expired = state
.bootstraps
.iter()
.filter_map(|(hash, bootstrap)| (now > bootstrap.expires_at).then_some(*hash))
.collect::<Vec<_>>();
for hash in expired {
remove_bootstrap_locked(state, hash);
}
state
.closed_tokens
.retain(|_, closed| now <= closed.expires_at);
}
/// Removes one bootstrap and releases its per-address issuance quota when unused.
pub(super) fn remove_bootstrap_locked(state: &mut ManagerState, hash: TokenHash) {
let Some(bootstrap) = state.bootstraps.remove(&hash) else {
return;
};
if !bootstrap.used {
decrement_map(&mut state.bootstraps_per_ip, &bootstrap.issuance_ip);
}
}
/// Decrements one counted owner and removes its map entry at zero.
pub(super) fn decrement_map<K, Q>(values: &mut HashMap<K, usize>, key: &Q)
where
K: std::borrow::Borrow<Q> + std::hash::Hash + Eq,
Q: std::hash::Hash + Eq + ?Sized,
{
let remove = if let Some(value) = values.get_mut(key) {
*value = value.saturating_sub(1);
*value == 0
} else {
false
};
if remove {
values.remove(key);
}
}
/// Computes the process-wide item reserve required for session control progress.
pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize {
limits.max_sessions_global.saturating_mul(
16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)),
)
}
/// Allocates a non-zero source port unique among live streams for one KDF route.
pub(super) fn allocate_stream_port(
state: &mut ManagerState,
client_ip: IpAddr,
public_addr: SocketAddr,
) -> Option<u16> {
let ports = state
.stream_ports
.entry((client_ip, public_addr))
.or_insert_with(|| StreamPortState {
active: HashSet::new(),
next: 1,
});
for _ in 0..u16::MAX {
let candidate = ports.next;
ports.next = ports.next.checked_add(1).unwrap_or(1);
if ports.active.insert(candidate) {
return Some(candidate);
}
}
None
}
/// Releases one source port and reclaims empty per-route allocator state.
pub(super) fn release_stream_port(
state: &mut ManagerState,
client_ip: IpAddr,
public_addr: SocketAddr,
peer_port: u16,
) -> bool {
let key = (client_ip, public_addr);
let Some(ports) = state.stream_ports.get_mut(&key) else {
return false;
};
let removed = ports.active.remove(&peer_port);
if ports.active.is_empty() {
state.stream_ports.remove(&key);
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_ports_are_unique_per_live_route_and_state_is_reclaimed() {
let mut state = ManagerState::default();
let client_ip = "192.0.2.10".parse().unwrap();
let public_addr = "203.0.113.10:443".parse().unwrap();
let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
let second = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
assert_ne!(first, second);
assert!(release_stream_port(
&mut state,
client_ip,
public_addr,
first,
));
assert!(release_stream_port(
&mut state,
client_ip,
public_addr,
second,
));
assert!(state.stream_ports.is_empty());
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Bounded WEB carrier ingress behind a trusted external TLS terminator.
/// Browser bridge generation for the serialized HTTPS carrier.
pub(crate) mod bridge;
/// Shared binary frame codec and protocol constants.
pub(crate) mod frame;
/// Plain HTTP ingress and decoy routing behind external TLS termination.
pub(crate) mod http;
/// Process-wide credentials, quotas, memory budgets, and shutdown ownership.
pub(crate) mod manager;
/// Resumable carrier sessions and logical-stream state machines.
pub(crate) mod session;
/// AsyncRead and AsyncWrite adapter for one logical MTProxy stream.
pub(crate) mod stream;
+409
View File
@@ -0,0 +1,409 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant};
use bytes::{Bytes, BytesMut};
use parking_lot::Mutex;
use tokio::io::ReadBuf;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
use crate::web::frame::{self, FrameType};
use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
mod backend;
// Downlink queues own cursor replay, flow control, and memory reservations.
mod downlink;
// Lane carrier state isolates request sequencing and downlink replay per logical stream.
mod lanes;
// Uplink batches own exactly-once sequencing and client-frame validation.
mod uplink;
/// Conservative allocator and container overhead charged to every queued item.
pub(crate) const QUEUE_ITEM_COST: usize = 256;
#[derive(Clone, Copy, PartialEq, Eq)]
enum PendingClass {
Uplink,
Downlink,
Control,
}
struct InboundChunk {
bytes: Bytes,
offset: usize,
}
struct StreamState {
inbound: VecDeque<InboundChunk>,
receive_window: u32,
send_credit: u64,
read_waker: Option<Waker>,
write_waker: Option<Waker>,
}
struct QueuedFrame {
encoded: BytesMut,
frame_type: FrameType,
stream_id: u32,
control: bool,
cost: usize,
}
struct DownBatch {
body: Bytes,
base_cursor: u64,
next_cursor: u64,
data_bytes: usize,
data_items: usize,
control_bytes: usize,
control_items: usize,
}
struct CarrierLane {
pending_frames: VecDeque<QueuedFrame>,
pending_windows: HashMap<u32, usize>,
unacked: Option<DownBatch>,
down_cursor: u64,
down_epoch: u64,
last_up_sequence: u64,
last_up_digest: TokenHash,
up_active: bool,
notify: Arc<Notify>,
}
impl CarrierLane {
fn new() -> Self {
Self {
pending_frames: VecDeque::new(),
pending_windows: HashMap::new(),
unacked: None,
down_cursor: 0,
down_epoch: 0,
last_up_sequence: 0,
last_up_digest: [0; 32],
up_active: false,
notify: Arc::new(Notify::new()),
}
}
}
struct SessionState {
streams: HashMap<u32, StreamState>,
active_peer_ports: HashSet<u16>,
closed_streams: HashSet<u32>,
closed_order: VecDeque<u32>,
pending_frames: VecDeque<QueuedFrame>,
pending_windows: HashMap<u32, usize>,
unacked: Option<DownBatch>,
down_cursor: u64,
down_epoch: u64,
last_up_sequence: u64,
last_up_digest: TokenHash,
carrier_lanes: HashMap<u32, CarrierLane>,
pending_bytes: usize,
pending_items: usize,
pending_control_bytes: usize,
pending_control_items: usize,
last_activity: Instant,
closed: bool,
}
/// One bounded WEB carrier session containing logical MTProxy streams.
pub(crate) struct WebSession {
manager: std::sync::Weak<WebProcessRuntime>,
token_hash: TokenHash,
client_ip: IpAddr,
profile: Arc<WebRuntimeProfile>,
profile_key: ProfileKey,
limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig,
state: Mutex<SessionState>,
down_notify: Arc<Notify>,
cancel: CancellationToken,
tasks_live: AtomicUsize,
tasks_done: Arc<Notify>,
finished: AtomicBool,
up_active: AtomicBool,
}
/// One successful downlink poll result.
pub(crate) struct PollResult {
/// Encoded downlink frame batch, or an empty long-poll result.
pub(crate) body: Bytes,
/// Cursor the client must present on its next downlink request.
pub(crate) next_cursor: u64,
/// Indicates that a drained non-zero lane no longer needs polling.
pub(crate) lane_closed: bool,
}
impl WebSession {
#[allow(clippy::too_many_arguments)]
/// Creates one carrier session with immutable ownership and allocation policy.
pub(crate) fn new(
manager: std::sync::Weak<WebProcessRuntime>,
token_hash: TokenHash,
client_ip: IpAddr,
profile: Arc<WebRuntimeProfile>,
profile_key: ProfileKey,
limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig,
) -> Arc<Self> {
let mut carrier_lanes = HashMap::new();
if profile.carrier == WebCarrier::HttpsLanes {
carrier_lanes.insert(0, CarrierLane::new());
}
Arc::new(Self {
manager,
token_hash,
client_ip,
profile,
profile_key,
limits,
timeouts,
state: Mutex::new(SessionState {
streams: HashMap::new(),
active_peer_ports: HashSet::new(),
closed_streams: HashSet::new(),
closed_order: VecDeque::new(),
pending_frames: VecDeque::new(),
pending_windows: HashMap::new(),
unacked: None,
down_cursor: 0,
down_epoch: 0,
last_up_sequence: 0,
last_up_digest: [0; 32],
carrier_lanes,
pending_bytes: 0,
pending_items: 0,
pending_control_bytes: 0,
pending_control_items: 0,
last_activity: Instant::now(),
closed: false,
}),
down_notify: Arc::new(Notify::new()),
cancel: CancellationToken::new(),
tasks_live: AtomicUsize::new(0),
tasks_done: Arc::new(Notify::new()),
finished: AtomicBool::new(false),
up_active: AtomicBool::new(false),
})
}
/// Returns the stable hashed token identity without exposing the credential.
pub(crate) fn token_hash(&self) -> TokenHash {
self.token_hash
}
/// Checks the canonical virtual host that owns this bearer session.
pub(crate) fn matches_host(&self, host: &str) -> bool {
self.profile.host == host
}
/// Returns the immutable carrier selected when this session was created.
pub(crate) fn carrier(&self) -> WebCarrier {
self.profile.carrier
}
/// Closes carrier state while relay tasks retain their admission until exit.
pub(crate) fn close(&self) {
let (data_bytes, data_items, control_bytes, control_items) = {
let mut state = self.state.lock();
if state.closed {
return;
}
state.closed = true;
for stream in state.streams.values_mut() {
if let Some(waker) = stream.read_waker.take() {
waker.wake();
}
if let Some(waker) = stream.write_waker.take() {
waker.wake();
}
}
state.streams.clear();
state.pending_frames.clear();
state.pending_windows.clear();
state.unacked = None;
for lane in state.carrier_lanes.values() {
lane.notify.notify_waiters();
}
state.carrier_lanes.clear();
let control_bytes = state.pending_control_bytes;
let control_items = state.pending_control_items;
let data_bytes = state.pending_bytes.saturating_sub(control_bytes);
let data_items = state.pending_items.saturating_sub(control_items);
state.pending_bytes = 0;
state.pending_items = 0;
state.pending_control_bytes = 0;
state.pending_control_items = 0;
(data_bytes, data_items, control_bytes, control_items)
};
self.cancel.cancel();
if self.carrier() == WebCarrier::Https {
self.down_notify.notify_waiters();
}
if let Some(manager) = self.manager.upgrade() {
manager.release_pending(data_bytes, data_items, false);
manager.release_pending(control_bytes, control_items, true);
if !self.finished.swap(true, Ordering::AcqRel) {
manager.session_finished(
self.token_hash,
self.client_ip,
self.profile_key,
&self.profile.host,
);
}
}
}
/// Waits for all logical-stream tasks after admission has closed.
pub(crate) async fn wait(&self) {
loop {
let notified = self.tasks_done.notified();
if self.tasks_live.load(Ordering::Acquire) == 0 {
return;
}
notified.await;
}
}
/// Returns whether reconnect grace elapsed without activity.
pub(crate) fn is_idle(&self, now: Instant) -> bool {
let state = self.state.lock();
!state.closed
&& now.saturating_duration_since(state.last_activity)
>= Duration::from_secs(self.timeouts.reconnect_grace_secs)
}
/// Polls client-to-server bytes and returns consumed flow-control credit.
pub(super) fn poll_read(
&self,
stream_id: u32,
cx: &mut Context<'_>,
output: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let mut state = self.state.lock();
let (count, finished) = {
let Some(stream) = state.streams.get_mut(&stream_id) else {
return Poll::Ready(Ok(()));
};
let Some(chunk) = stream.inbound.front_mut() else {
stream.read_waker = Some(cx.waker().clone());
return Poll::Pending;
};
let available = &chunk.bytes[chunk.offset..];
let count = available.len().min(output.remaining());
output.put_slice(&available[..count]);
chunk.offset += count;
let finished = chunk.offset == chunk.bytes.len();
if finished {
stream.inbound.pop_front();
}
stream.receive_window = stream.receive_window.saturating_add(count as u32);
(count, finished)
};
let overhead = if finished { QUEUE_ITEM_COST } else { 0 };
self.release_locked(&mut state, count + overhead, usize::from(finished), false);
if !self.queue_window_locked(&mut state, stream_id, count as u32) {
drop(state);
self.close();
return Poll::Ready(Err(io::Error::other("WEB session control budget exhausted")));
}
Poll::Ready(Ok(()))
}
/// Polls server-to-client writes against stream credit and bounded queues.
pub(super) fn poll_write(
&self,
stream_id: u32,
cx: &mut Context<'_>,
input: &[u8],
) -> Poll<io::Result<usize>> {
if input.is_empty() {
return Poll::Ready(Ok(0));
}
let mut state = self.state.lock();
let Some(stream) = state.streams.get_mut(&stream_id) else {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"WEB logical stream is closed",
)));
};
let count = input
.len()
.min(frame::DATA_CHUNK_BYTES)
.min(self.limits.max_frame_payload_bytes)
.min(stream.send_credit as usize);
if count == 0 {
stream.write_waker = Some(cx.waker().clone());
return Poll::Pending;
}
if !self.queue_data_locked(&mut state, stream_id, &input[..count]) {
if let Some(stream) = state.streams.get_mut(&stream_id) {
stream.write_waker = Some(cx.waker().clone());
}
return Poll::Pending;
}
let Some(stream) = state.streams.get_mut(&stream_id) else {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"WEB logical stream is closed",
)));
};
stream.send_credit -= count as u64;
state.last_activity = Instant::now();
drop(state);
if self.carrier() == WebCarrier::Https {
self.down_notify.notify_waiters();
}
Poll::Ready(Ok(count))
}
/// Returns the process queue-capacity notification source while the manager lives.
pub(super) fn budget_notify(&self) -> Option<Arc<Notify>> {
self.manager.upgrade().map(|manager| manager.budget_notify())
}
fn release_stream_reservation(&self, peer_port: u16) {
let removed = self.state.lock().active_peer_ports.remove(&peer_port);
if removed
&& let Some(manager) = self.manager.upgrade()
{
manager.release_stream(
self.profile_key,
self.client_ip,
self.profile.public_addr,
peer_port,
);
}
}
}
fn inbound_queue_cost(queue: &VecDeque<InboundChunk>) -> (usize, usize) {
let bytes = queue.iter().fold(0usize, |total, chunk| {
total.saturating_add(chunk.bytes.len().saturating_sub(chunk.offset) + QUEUE_ITEM_COST)
});
(bytes, queue.len())
}
fn remember_closed(state: &mut SessionState, stream_id: u32, limit: usize) -> Option<u32> {
if !state.closed_streams.insert(stream_id) {
return None;
}
state.closed_order.push_back(stream_id);
let mut evicted = None;
while state.closed_order.len() > limit {
if let Some(oldest) = state.closed_order.pop_front() {
state.closed_streams.remove(&oldest);
evicted = Some(oldest);
}
}
evicted
}
+166
View File
@@ -0,0 +1,166 @@
use std::io;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::web::frame::FrameType;
use crate::web::stream::WebLogicalStream;
use crate::proxy::shared_state::ConntrackClosePolicy;
use super::{WebSession, inbound_queue_cost};
impl WebSession {
/// Starts one owned inner handshake and relay task for an admitted stream.
pub(super) fn spawn_stream(self: &Arc<Self>, stream_id: u32, peer_port: u16) {
let Some(manager) = self.manager.upgrade() else {
self.stream_finished(stream_id, peer_port);
return;
};
let generation = manager.active_generation();
let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else {
manager.record_stream_rejected();
self.stream_finished(stream_id, peer_port);
return;
};
let Some(handshake_permit) = manager.try_stream_handshake() else {
self.stream_finished(stream_id, peer_port);
return;
};
let deps = generation.client_runtime_deps();
let replay_checker = Arc::clone(&generation.replay_checker);
let session = Arc::clone(self);
let cancel = self.cancel.clone();
self.tasks_live.fetch_add(1, Ordering::AcqRel);
let spawned = generation.spawn_session(async move {
let _connection_permit = connection_permit;
let _completion = StreamCompletion {
session: Arc::clone(&session),
stream_id,
peer_port,
};
let stream = WebLogicalStream::new(Arc::clone(&session), stream_id);
tokio::select! {
_ = cancel.cancelled() => {}
_ = run_stream(
Arc::clone(&session),
stream,
deps,
replay_checker,
handshake_permit,
peer_port,
) => {}
}
});
if !spawned {
self.tasks_live.fetch_sub(1, Ordering::AcqRel);
self.stream_finished(stream_id, peer_port);
self.tasks_done.notify_waiters();
}
}
fn stream_finished(&self, stream_id: u32, peer_port: u16) {
let (queued, reserved) = {
let mut state = self.state.lock();
let reserved = state.active_peer_ports.remove(&peer_port);
let queued = state.streams.remove(&stream_id).map(|stream| {
let (bytes, items) = inbound_queue_cost(&stream.inbound);
self.release_locked(&mut state, bytes, items, false);
self.remember_closed_locked(&mut state, stream_id);
self.queue_control_locked(&mut state, FrameType::Close, stream_id, &[])
});
(queued, reserved)
};
if reserved
&& let Some(manager) = self.manager.upgrade()
{
manager.release_stream(
self.profile_key,
self.client_ip,
self.profile.public_addr,
peer_port,
);
}
if let Some(queued) = queued {
if !queued {
self.close();
}
if self.carrier() == crate::config::WebCarrier::Https {
self.down_notify.notify_waiters();
}
}
}
}
struct StreamCompletion {
session: Arc<WebSession>,
stream_id: u32,
peer_port: u16,
}
impl Drop for StreamCompletion {
fn drop(&mut self) {
self.session.stream_finished(self.stream_id, self.peer_port);
if self.session.tasks_live.fetch_sub(1, Ordering::AcqRel) == 1 {
self.session.tasks_done.notify_waiters();
}
}
}
async fn run_stream(
session: Arc<WebSession>,
stream: WebLogicalStream,
deps: crate::proxy::authenticated::ClientRuntimeDeps,
replay_checker: Arc<crate::stats::ReplayChecker>,
handshake_permit: tokio::sync::OwnedSemaphorePermit,
peer_port: u16,
) {
use tokio::io::AsyncReadExt;
use crate::protocol::constants::HANDSHAKE_LEN;
use crate::proxy::authenticated::run_authenticated;
use crate::proxy::handshake::handle_mtproto_handshake_for_web_user;
let (mut reader, writer) = tokio::io::split(stream);
let mut handshake = [0u8; HANDSHAKE_LEN];
let peer = std::net::SocketAddr::new(session.client_ip, peer_port);
deps.stats.increment_connects_all();
let handshake_result = tokio::time::timeout(
Duration::from_secs(session.timeouts.stream_handshake_secs),
async {
reader.read_exact(&mut handshake).await?;
Ok::<_, io::Error>(
handle_mtproto_handshake_for_web_user(
&handshake,
reader,
writer,
peer,
&deps.config,
&replay_checker,
&session.profile.user,
session.profile.secret_mode,
&deps.shared,
)
.await,
)
},
)
.await;
drop(handshake_permit);
let Ok(Ok(crate::error::HandshakeResult::Success((reader, writer, success)))) =
handshake_result
else {
deps.stats
.increment_connects_bad_with_class("web_mtproto_bad_client");
return;
};
let _ = run_authenticated(
reader,
writer,
success,
deps,
session.profile.public_addr,
peer,
ConntrackClosePolicy::Suppress,
)
.await;
}
+523
View File
@@ -0,0 +1,523 @@
use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use super::{
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
WebSession,
};
use crate::config::WebCarrier;
use crate::web::frame::{self, FrameType};
use crate::web::manager::ManagerError;
impl WebSession {
/// Polls pending downlink frames with cursor replay and newest-poll-wins semantics.
pub(crate) async fn poll_down(&self, cursor: u64) -> Result<PollResult, ManagerError> {
if self.carrier() != WebCarrier::Https {
return Err(ManagerError::Protocol);
}
let epoch = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
if let Some(unacked) = &state.unacked {
if cursor == unacked.base_cursor {
return Ok(PollResult {
body: unacked.body.clone(),
next_cursor: unacked.next_cursor,
lane_closed: false,
});
}
if cursor != unacked.next_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
self.release_unacked_locked(&mut state);
} else if cursor != state.down_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
state.down_epoch = state.down_epoch.wrapping_add(1).max(1);
state.down_epoch
};
self.down_notify.notify_waiters();
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
let poll = async {
loop {
let notified = self.down_notify.notified();
{
let mut state = self.state.lock();
if state.down_epoch != epoch {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
});
}
if !state.pending_frames.is_empty() {
let batch = match self.take_down_batch_locked(&mut state, cursor) {
Ok(batch) => batch,
Err(error) => {
drop(state);
self.close();
return Err(error);
}
};
let result = PollResult {
body: batch.body.clone(),
next_cursor: batch.next_cursor,
lane_closed: false,
};
if let Some(manager) = self.manager.upgrade() {
manager.record_down(result.body.len());
}
state.unacked = Some(batch);
return Ok(result);
}
if state.closed {
return Err(ManagerError::Closed);
}
}
notified.await;
}
};
match tokio::time::timeout(deadline, poll).await {
Ok(result) => result,
Err(_) => {
let mut state = self.state.lock();
if state.down_epoch == epoch {
state.last_activity = Instant::now();
}
Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
})
}
}
}
/// Reserves session and process queue capacity while the session lock is held.
pub(super) fn reserve_locked(
&self,
state: &mut SessionState,
bytes: usize,
items: usize,
class: PendingClass,
) -> bool {
if bytes == 0 && items == 0 {
return true;
}
let data_byte_limit = self
.limits
.pending_bytes_per_session
.saturating_sub(self.limits.control_bytes_per_session);
let item_reserve = 16usize.saturating_add(
self.limits.max_streams_per_session.saturating_mul(3),
);
let data_item_limit = self
.limits
.pending_items_per_session
.saturating_sub(item_reserve);
if state.closed {
return false;
}
let control = class == PendingClass::Control;
let fits = if control {
bytes <= self.limits.control_bytes_per_session
&& items <= item_reserve
&& state.pending_bytes
<= self.limits.pending_bytes_per_session.saturating_sub(bytes)
&& state.pending_items
<= self.limits.pending_items_per_session.saturating_sub(items)
&& state.pending_control_bytes
<= self.limits.control_bytes_per_session.saturating_sub(bytes)
&& state.pending_control_items <= item_reserve.saturating_sub(items)
} else {
let data_bytes = state
.pending_bytes
.saturating_sub(state.pending_control_bytes);
let data_items = state
.pending_items
.saturating_sub(state.pending_control_items);
let (byte_limit, item_limit) = if class == PendingClass::Downlink {
let uplink_bytes = self
.limits
.max_body_bytes
.saturating_add(
self.limits
.max_frames_per_body
.saturating_mul(QUEUE_ITEM_COST),
);
(
data_byte_limit.saturating_sub(uplink_bytes),
data_item_limit.saturating_sub(self.limits.max_frames_per_body),
)
} else {
(data_byte_limit, data_item_limit)
};
bytes <= byte_limit
&& items <= item_limit
&& data_bytes <= byte_limit - bytes
&& data_items <= item_limit - items
};
if !fits {
return false;
}
let Some(manager) = self.manager.upgrade() else {
return false;
};
if !manager.try_reserve_pending(
bytes,
items,
control,
class == PendingClass::Downlink,
) {
return false;
}
state.pending_bytes += bytes;
state.pending_items += items;
if control {
state.pending_control_bytes += bytes;
state.pending_control_items += items;
}
true
}
/// Releases session and process queue capacity while the session lock is held.
pub(super) fn release_locked(
&self,
state: &mut SessionState,
bytes: usize,
items: usize,
control: bool,
) {
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
state.pending_items = state.pending_items.saturating_sub(items);
if control {
state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes);
state.pending_control_items = state.pending_control_items.saturating_sub(items);
}
if let Some(manager) = self.manager.upgrade() {
manager.release_pending(bytes, items, control);
}
}
/// Coalesces one flow-control update into the bounded control queue.
pub(super) fn queue_window_locked(
&self,
state: &mut SessionState,
stream_id: u32,
amount: u32,
) -> bool {
if amount == 0 {
return true;
}
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_control_locked(
state,
FrameType::Window,
stream_id,
&frame::window_payload(amount),
);
}
if let Some(index) = state.pending_windows.get(&stream_id).copied()
&& let Some(queued) = state.pending_frames.get_mut(index)
{
let previous = u32::from_be_bytes(
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.try_into()
.unwrap_or([0; 4]),
);
if let Some(total) = previous.checked_add(amount) {
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.copy_from_slice(&total.to_be_bytes());
self.down_notify.notify_waiters();
return true;
}
}
self.queue_control_locked(
state,
FrameType::Window,
stream_id,
&frame::window_payload(amount),
)
}
/// Appends one control frame under both reserved queue budgets.
pub(super) fn queue_control_locked(
&self,
state: &mut SessionState,
frame_type: FrameType,
stream_id: u32,
payload: &[u8],
) -> bool {
self.queue_frame_locked(state, frame_type, stream_id, payload, true)
}
/// Appends one server-to-client DATA frame under downlink data budgets.
pub(super) fn queue_data_locked(
&self,
state: &mut SessionState,
stream_id: u32,
payload: &[u8],
) -> bool {
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_frame_locked(state, FrameType::Data, stream_id, payload, false);
}
let can_coalesce = state.pending_frames.back().is_some_and(|last| {
last.frame_type == FrameType::Data
&& last.stream_id == stream_id
&& last.encoded.len() - frame::HEADER_BYTES + payload.len()
<= self.limits.max_frame_payload_bytes
});
if can_coalesce {
if !self.reserve_locked(state, payload.len(), 0, PendingClass::Downlink) {
return false;
}
let Some(last) = state.pending_frames.back_mut() else {
return false;
};
last.encoded.extend_from_slice(payload);
last.cost += payload.len();
let payload_len = (last.encoded.len() - frame::HEADER_BYTES) as u32;
last.encoded[4..8].copy_from_slice(&payload_len.to_be_bytes());
return true;
}
self.queue_frame_locked(state, FrameType::Data, stream_id, payload, false)
}
fn queue_frame_locked(
&self,
state: &mut SessionState,
frame_type: FrameType,
stream_id: u32,
payload: &[u8],
control: bool,
) -> bool {
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_lane_frame_locked(
state,
frame_type,
stream_id,
payload,
control,
);
}
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
let class = if control {
PendingClass::Control
} else {
PendingClass::Downlink
};
if !self.reserve_locked(state, cost, 1, class) {
return false;
}
let mut encoded = BytesMut::with_capacity(frame::HEADER_BYTES + payload.len());
encoded.put_u8(frame_type as u8);
encoded.put_u8((stream_id >> 16) as u8);
encoded.put_u8((stream_id >> 8) as u8);
encoded.put_u8(stream_id as u8);
encoded.put_u32(payload.len() as u32);
encoded.extend_from_slice(payload);
let index = state.pending_frames.len();
state.pending_frames.push_back(QueuedFrame {
encoded,
frame_type,
stream_id,
control,
cost,
});
if frame_type == FrameType::Window {
state.pending_windows.insert(stream_id, index);
}
self.down_notify.notify_waiters();
true
}
fn take_down_batch_locked(
&self,
state: &mut SessionState,
cursor: u64,
) -> Result<DownBatch, ManagerError> {
let next_cursor = state
.down_cursor
.checked_add(1)
.ok_or(ManagerError::Protocol)?;
let mut count = 0usize;
let mut body_len = 0usize;
for queued in &state.pending_frames {
if count >= self.limits.max_frames_per_body
|| (count != 0
&& body_len.saturating_add(queued.encoded.len())
> self.limits.carrier_batch_bytes)
{
break;
}
body_len += queued.encoded.len();
count += 1;
}
let mut body = BytesMut::with_capacity(body_len);
let mut data_bytes = 0usize;
let mut data_items = 0usize;
let mut control_bytes = 0usize;
let mut control_items = 0usize;
for index in 0..count {
let Some(queued) = state.pending_frames.get(index) else {
break;
};
if queued.frame_type == FrameType::Window
&& state.pending_windows.get(&queued.stream_id) == Some(&index)
{
state.pending_windows.remove(&queued.stream_id);
}
}
for _ in 0..count {
let Some(queued) = state.pending_frames.pop_front() else {
break;
};
body.extend_from_slice(&queued.encoded);
if queued.control {
control_bytes += queued.cost;
control_items += 1;
} else {
data_bytes += queued.cost;
data_items += 1;
}
}
for index in state.pending_windows.values_mut() {
*index = index.saturating_sub(count);
}
state.down_cursor = next_cursor;
Ok(DownBatch {
body: body.freeze(),
base_cursor: cursor,
next_cursor,
data_bytes,
data_items,
control_bytes,
control_items,
})
}
fn release_unacked_locked(&self, state: &mut SessionState) {
let Some(batch) = state.unacked.take() else {
return;
};
self.release_locked(state, batch.data_bytes, batch.data_items, false);
self.release_locked(state, batch.control_bytes, batch.control_items, true);
for stream in state.streams.values_mut() {
if let Some(waker) = stream.write_waker.take() {
waker.wake();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::config::{
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::WebProcessRuntime;
fn session() -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https,
capability: [0; 32],
max_sessions: 1,
max_streams: 1,
max_streams_per_session: 1,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
profile,
[2; 32],
WebLimitsConfig::default(),
WebTimeoutsConfig::default(),
)
}
fn queue_close(session: &WebSession) {
let encoded = frame::encode(FrameType::Close, 1, &[]);
session.state.lock().pending_frames.push_back(QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 1,
control: true,
cost: frame::HEADER_BYTES + QUEUE_ITEM_COST,
});
}
#[tokio::test]
async fn downlink_replays_unacknowledged_batch_byte_for_byte() {
let session = session();
queue_close(&session);
let first = session.poll_down(0).await.unwrap();
let replay = session.poll_down(0).await.unwrap();
assert_eq!(first.next_cursor, 1);
assert_eq!(replay.next_cursor, 1);
assert_eq!(first.body, replay.body);
}
#[tokio::test]
async fn invalid_or_overflowing_cursor_closes_session() {
let invalid = session();
assert!(matches!(
invalid.poll_down(1).await,
Err(ManagerError::Protocol)
));
assert!(invalid.state.lock().closed);
let overflow = session();
{
let mut state = overflow.state.lock();
state.down_cursor = u64::MAX;
}
queue_close(&overflow);
assert!(matches!(
overflow.poll_down(u64::MAX).await,
Err(ManagerError::Protocol)
));
assert!(overflow.state.lock().closed);
}
#[tokio::test]
async fn newer_poll_supersedes_older_poll_without_closing_session() {
let session = session();
let first_session = Arc::clone(&session);
let first = tokio::spawn(async move { first_session.poll_down(0).await });
while session.state.lock().down_epoch < 1 {
tokio::task::yield_now().await;
}
let second_session = Arc::clone(&session);
let second = tokio::spawn(async move { second_session.poll_down(0).await });
while session.state.lock().down_epoch < 2 {
tokio::task::yield_now().await;
}
let superseded = tokio::time::timeout(Duration::from_secs(1), first)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(superseded.body.is_empty());
assert_eq!(superseded.next_cursor, 0);
assert!(!session.state.lock().closed);
second.abort();
}
}
+525
View File
@@ -0,0 +1,525 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use super::uplink::{inbound_reservation, validate_batch};
use super::{
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame,
SessionState, WebSession, remember_closed,
};
use crate::config::{WebCarrier, WebLimitsConfig};
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
impl WebSession {
/// Applies one exactly-once uplink batch to an independent HTTPS lane.
pub(crate) fn process_up_lane(
self: &Arc<Self>,
lane_id: u32,
sequence: u64,
body: &[u8],
) -> Result<u64, ManagerError> {
if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID {
return Err(ManagerError::Protocol);
}
let frames = match frame::parse_all(body, &self.limits) {
Ok(frames) => frames,
Err(_) => {
self.close();
return Err(ManagerError::Protocol);
}
};
if frames.iter().copied().any(|value| {
value.stream_id != lane_id || frame::validate_client_shape(value).is_err()
}) {
self.close();
return Err(ManagerError::Protocol);
}
let digest: TokenHash = Sha256::digest(body).into();
let mut opened = Vec::new();
let result = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
if !state.carrier_lanes.contains_key(&lane_id) {
if lane_id != 0
&& frames.first().is_some_and(|value| value.frame_type != FrameType::Open)
&& only_late_frames(&frames)
{
return Ok(sequence);
}
if lane_id == 0
|| frames.first().is_none_or(|value| value.frame_type != FrameType::Open)
{
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
state.carrier_lanes.insert(lane_id, CarrierLane::new());
}
let lane = state
.carrier_lanes
.get_mut(&lane_id)
.ok_or(ManagerError::Protocol)?;
if sequence == lane.last_up_sequence && sequence != 0 {
return if bool::from(lane.last_up_digest.ct_eq(&digest)) {
Ok(sequence)
} else {
drop(state);
self.close();
Err(ManagerError::Protocol)
};
}
if sequence == 0 || sequence != lane.last_up_sequence.saturating_add(1) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
if lane.up_active {
return Err(ManagerError::Concurrent);
}
lane.up_active = true;
if !validate_batch(&state, &frames) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
if !self.reserve_locked(
&mut state,
reserve_bytes,
reserve_items,
PendingClass::Uplink,
) {
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
lane.up_active = false;
}
return Err(ManagerError::Backpressure);
}
let mut unused_bytes = reserve_bytes;
let mut unused_items = reserve_items;
let applied = self.apply_batch_locked(
&mut state,
&frames,
&mut opened,
&mut unused_bytes,
&mut unused_items,
);
self.release_locked(&mut state, unused_bytes, unused_items, false);
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
lane.up_active = false;
if applied {
lane.last_up_sequence = sequence;
lane.last_up_digest = digest;
}
}
applied.then_some(sequence).ok_or(ManagerError::Closed)
};
if matches!(result, Err(ManagerError::Backpressure)) {
return result;
}
if result.is_err() {
self.close();
for (_, peer_port) in opened {
self.release_stream_reservation(peer_port);
}
return result;
}
for (stream_id, peer_port) in opened {
self.spawn_stream(stream_id, peer_port);
}
if let Some(manager) = self.manager.upgrade() {
manager.record_up(body.len());
}
result
}
/// Polls one lane with independent cursor replay and newest-poll-wins semantics.
pub(crate) async fn poll_down_lane(
&self,
lane_id: u32,
cursor: u64,
) -> Result<PollResult, ManagerError> {
if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID {
return Err(ManagerError::Protocol);
}
let (epoch, notify) = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
let acknowledged = {
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
return Err(ManagerError::Protocol);
};
if let Some(unacked) = &lane.unacked {
if cursor == unacked.base_cursor {
return Ok(PollResult {
body: unacked.body.clone(),
next_cursor: unacked.next_cursor,
lane_closed: false,
});
}
if cursor != unacked.next_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
lane.unacked.take()
} else {
if cursor != lane.down_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
None
}
};
if let Some(batch) = acknowledged {
self.release_locked(&mut state, batch.data_bytes, batch.data_items, false);
self.release_locked(
&mut state,
batch.control_bytes,
batch.control_items,
true,
);
if let Some(stream) = state.streams.get_mut(&lane_id)
&& let Some(waker) = stream.write_waker.take()
{
waker.wake();
}
}
state.last_activity = Instant::now();
let lane = state
.carrier_lanes
.get_mut(&lane_id)
.ok_or(ManagerError::Protocol)?;
lane.down_epoch = lane.down_epoch.wrapping_add(1).max(1);
(lane.down_epoch, Arc::clone(&lane.notify))
};
notify.notify_waiters();
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
let poll = async {
loop {
let notified = notify.notified();
{
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
};
if lane.down_epoch != epoch {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
});
}
if !lane.pending_frames.is_empty() {
let batch = match take_lane_down_batch(&self.limits, lane, cursor) {
Ok(batch) => batch,
Err(error) => {
drop(state);
self.close();
return Err(error);
}
};
let result = PollResult {
body: batch.body.clone(),
next_cursor: batch.next_cursor,
lane_closed: false,
};
lane.unacked = Some(batch);
drop(state);
if let Some(manager) = self.manager.upgrade() {
manager.record_down(result.body.len());
}
return Ok(result);
}
if lane_id != 0
&& !state.streams.contains_key(&lane_id)
&& state.closed_streams.contains(&lane_id)
{
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
}
notified.await;
}
};
match tokio::time::timeout(deadline, poll).await {
Ok(result) => result,
Err(_) => {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
if !state.carrier_lanes.contains_key(&lane_id) {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
if lane_id != 0
&& !state.streams.contains_key(&lane_id)
&& state.closed_streams.contains(&lane_id)
{
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
if state
.carrier_lanes
.get(&lane_id)
.is_some_and(|lane| lane.down_epoch == epoch)
{
state.last_activity = Instant::now();
}
Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
})
}
}
}
pub(super) fn queue_lane_frame_locked(
&self,
state: &mut SessionState,
frame_type: FrameType,
stream_id: u32,
payload: &[u8],
control: bool,
) -> bool {
if !state.carrier_lanes.contains_key(&stream_id) {
return false;
}
if frame_type == FrameType::Window {
let coalesced = state.carrier_lanes.get(&stream_id).and_then(|lane| {
let index = lane.pending_windows.get(&stream_id).copied()?;
let queued = lane.pending_frames.get(index)?;
let previous = u32::from_be_bytes(
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.try_into()
.unwrap_or([0; 4]),
);
previous
.checked_add(frame::window_amount(payload).unwrap_or(0))
.map(|total| (index, total))
});
if let Some((index, total)) = coalesced
&& let Some(lane) = state.carrier_lanes.get_mut(&stream_id)
&& let Some(queued) = lane.pending_frames.get_mut(index)
{
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.copy_from_slice(&total.to_be_bytes());
lane.notify.notify_waiters();
return true;
}
}
let can_coalesce = frame_type == FrameType::Data
&& state
.carrier_lanes
.get(&stream_id)
.and_then(|lane| lane.pending_frames.back())
.is_some_and(|last| {
last.frame_type == FrameType::Data
&& last.stream_id == stream_id
&& last.encoded.len() - frame::HEADER_BYTES + payload.len()
<= self.limits.max_frame_payload_bytes
});
if can_coalesce {
if !self.reserve_locked(state, payload.len(), 0, PendingClass::Downlink) {
return false;
}
let Some(lane) = state.carrier_lanes.get_mut(&stream_id) else {
self.release_locked(state, payload.len(), 0, false);
return false;
};
let Some(last) = lane.pending_frames.back_mut() else {
self.release_locked(state, payload.len(), 0, false);
return false;
};
last.encoded.extend_from_slice(payload);
last.cost += payload.len();
let payload_len = (last.encoded.len() - frame::HEADER_BYTES) as u32;
last.encoded[4..8].copy_from_slice(&payload_len.to_be_bytes());
lane.notify.notify_waiters();
return true;
}
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
let class = if control {
PendingClass::Control
} else {
PendingClass::Downlink
};
if !self.reserve_locked(state, cost, 1, class) {
return false;
}
let mut encoded = BytesMut::with_capacity(frame::HEADER_BYTES + payload.len());
encoded.put_u8(frame_type as u8);
encoded.put_u8((stream_id >> 16) as u8);
encoded.put_u8((stream_id >> 8) as u8);
encoded.put_u8(stream_id as u8);
encoded.put_u32(payload.len() as u32);
encoded.extend_from_slice(payload);
let Some(lane) = state.carrier_lanes.get_mut(&stream_id) else {
self.release_locked(state, cost, 1, control);
return false;
};
let index = lane.pending_frames.len();
lane.pending_frames.push_back(QueuedFrame {
encoded,
frame_type,
stream_id,
control,
cost,
});
if frame_type == FrameType::Window {
lane.pending_windows.insert(stream_id, index);
}
lane.notify.notify_waiters();
true
}
pub(super) fn remember_closed_locked(&self, state: &mut SessionState, stream_id: u32) {
let evicted = remember_closed(
state,
stream_id,
self.limits.max_tombstones_per_session,
);
if self.carrier() != WebCarrier::HttpsLanes {
return;
}
if let Some(evicted) = evicted {
self.release_lane_locked(state, evicted);
}
if let Some(lane) = state.carrier_lanes.get(&stream_id) {
lane.notify.notify_waiters();
}
}
fn release_lane_locked(&self, state: &mut SessionState, lane_id: u32) {
let Some(mut lane) = state.carrier_lanes.remove(&lane_id) else {
return;
};
lane.notify.notify_waiters();
let mut data_bytes = 0usize;
let mut data_items = 0usize;
let mut control_bytes = 0usize;
let mut control_items = 0usize;
for queued in lane.pending_frames.drain(..) {
if queued.control {
control_bytes = control_bytes.saturating_add(queued.cost);
control_items = control_items.saturating_add(1);
} else {
data_bytes = data_bytes.saturating_add(queued.cost);
data_items = data_items.saturating_add(1);
}
}
if let Some(batch) = lane.unacked.take() {
data_bytes = data_bytes.saturating_add(batch.data_bytes);
data_items = data_items.saturating_add(batch.data_items);
control_bytes = control_bytes.saturating_add(batch.control_bytes);
control_items = control_items.saturating_add(batch.control_items);
}
self.release_locked(state, data_bytes, data_items, false);
self.release_locked(state, control_bytes, control_items, true);
}
}
fn only_late_frames(frames: &[Frame<'_>]) -> bool {
frames.iter().all(|value| {
matches!(
value.frame_type,
FrameType::Data | FrameType::Window | FrameType::Close
)
})
}
fn take_lane_down_batch(
limits: &WebLimitsConfig,
lane: &mut CarrierLane,
cursor: u64,
) -> Result<DownBatch, ManagerError> {
let next_cursor = lane
.down_cursor
.checked_add(1)
.ok_or(ManagerError::Protocol)?;
let mut count = 0usize;
let mut body_len = 0usize;
for queued in &lane.pending_frames {
if count >= limits.max_frames_per_body
|| (count != 0
&& body_len.saturating_add(queued.encoded.len()) > limits.carrier_batch_bytes)
{
break;
}
body_len += queued.encoded.len();
count += 1;
}
let mut body = BytesMut::with_capacity(body_len);
let mut data_bytes = 0usize;
let mut data_items = 0usize;
let mut control_bytes = 0usize;
let mut control_items = 0usize;
for index in 0..count {
let Some(queued) = lane.pending_frames.get(index) else {
break;
};
if queued.frame_type == FrameType::Window
&& lane.pending_windows.get(&queued.stream_id) == Some(&index)
{
lane.pending_windows.remove(&queued.stream_id);
}
}
for _ in 0..count {
let Some(queued) = lane.pending_frames.pop_front() else {
break;
};
body.extend_from_slice(&queued.encoded);
if queued.control {
control_bytes += queued.cost;
control_items += 1;
} else {
data_bytes += queued.cost;
data_items += 1;
}
}
for index in lane.pending_windows.values_mut() {
*index = index.saturating_sub(count);
}
lane.down_cursor = next_cursor;
Ok(DownBatch {
body: body.freeze(),
base_cursor: cursor,
next_cursor,
data_bytes,
data_items,
control_bytes,
control_items,
})
}
// Lane-specific protocol, replay, and lifecycle tests.
#[cfg(test)]
mod tests;
+129
View File
@@ -0,0 +1,129 @@
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::BytesMut;
use super::*;
use crate::config::{
WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::WebProcessRuntime;
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::HttpsLanes,
capability: [0; 32],
max_sessions: 1,
max_streams: 2,
max_streams_per_session: 2,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
profile,
[2; 32],
limits,
WebTimeoutsConfig::default(),
)
}
fn session() -> Arc<WebSession> {
session_with_limits(WebLimitsConfig::default())
}
#[test]
fn lane_uplink_sequences_are_independent_and_exactly_once() {
let session = session();
{
let mut state = session.state.lock();
for lane_id in [51, 52] {
state.carrier_lanes.insert(lane_id, CarrierLane::new());
state.closed_streams.insert(lane_id);
}
}
let first = frame::encode(FrameType::Data, 51, b"first");
let second = frame::encode(FrameType::Data, 52, b"second");
assert_eq!(session.process_up_lane(51, 1, &first), Ok(1));
assert_eq!(session.process_up_lane(52, 1, &second), Ok(1));
assert_eq!(session.process_up_lane(51, 1, &first), Ok(1));
assert_eq!(session.state.lock().carrier_lanes[&52].last_up_sequence, 1);
}
#[test]
fn cross_lane_frame_is_fatal_to_https_lane_session() {
let session = session();
let body = frame::encode(FrameType::Data, 52, b"wrong lane");
assert_eq!(
session.process_up_lane(51, 1, &body),
Err(ManagerError::Protocol)
);
assert!(session.state.lock().closed);
}
#[tokio::test]
async fn drained_closed_lane_replays_then_signals_completion() {
let session = session();
{
let mut state = session.state.lock();
state.carrier_lanes.insert(7, CarrierLane::new());
state.closed_streams.insert(7);
let lane = state.carrier_lanes.get_mut(&7).unwrap();
let encoded = frame::encode(FrameType::Close, 7, &[]);
lane.pending_frames.push_back(QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 7,
control: true,
cost: frame::HEADER_BYTES + QUEUE_ITEM_COST,
});
}
let first = session.poll_down_lane(7, 0).await.unwrap();
let replay = session.poll_down_lane(7, 0).await.unwrap();
assert_eq!(first.body, replay.body);
assert!(!replay.lane_closed);
let finished = session.poll_down_lane(7, 1).await.unwrap();
assert!(finished.body.is_empty());
assert!(finished.lane_closed);
}
#[test]
fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
let limits = WebLimitsConfig {
max_tombstones_per_session: 1,
..WebLimitsConfig::default()
};
let session = session_with_limits(limits);
{
let mut state = session.state.lock();
state.carrier_lanes.insert(7, CarrierLane::new());
let encoded = frame::encode(FrameType::Close, 7, &[]);
let cost = encoded.len() + QUEUE_ITEM_COST;
state.carrier_lanes.get_mut(&7).unwrap().pending_frames.push_back(
QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 7,
control: true,
cost,
},
);
state.pending_bytes = cost;
state.pending_items = 1;
state.pending_control_bytes = cost;
state.pending_control_items = 1;
session.remember_closed_locked(&mut state, 7);
state.carrier_lanes.insert(8, CarrierLane::new());
session.remember_closed_locked(&mut state, 8);
assert!(!state.carrier_lanes.contains_key(&7));
assert_eq!(state.pending_bytes, 0);
assert_eq!(state.pending_items, 0);
}
let late = frame::encode(FrameType::Data, 7, b"late");
assert_eq!(session.process_up_lane(7, 7, &late), Ok(7));
assert!(!session.state.lock().closed);
}
+431
View File
@@ -0,0 +1,431 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use bytes::Bytes;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use super::{
InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionState, StreamState, WebSession,
inbound_queue_cost,
};
use crate::config::WebCarrier;
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
impl WebSession {
/// Applies one exactly-once uplink batch.
pub(crate) fn process_up(
self: &Arc<Self>,
sequence: u64,
body: &[u8],
) -> Result<u64, ManagerError> {
if self.carrier() != WebCarrier::Https {
return Err(ManagerError::Protocol);
}
if self
.up_active
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(ManagerError::Concurrent);
}
let _uplink = UplinkGuard(&self.up_active);
let frames = match frame::parse_all(body, &self.limits) {
Ok(frames) => frames,
Err(_) => {
self.close();
return Err(ManagerError::Protocol);
}
};
if frames
.iter()
.copied()
.any(|value| frame::validate_client_shape(value).is_err())
{
self.close();
return Err(ManagerError::Protocol);
}
let digest: TokenHash = Sha256::digest(body).into();
let mut opened = Vec::new();
let result = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
if sequence == state.last_up_sequence && sequence != 0 {
return if bool::from(state.last_up_digest.ct_eq(&digest)) {
Ok(sequence)
} else {
drop(state);
self.close();
Err(ManagerError::Protocol)
};
}
if sequence == 0 || sequence != state.last_up_sequence.saturating_add(1) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
if !validate_batch(&state, &frames) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
if !self.reserve_locked(
&mut state,
reserve_bytes,
reserve_items,
PendingClass::Uplink,
) {
return Err(ManagerError::Backpressure);
}
let mut unused_bytes = reserve_bytes;
let mut unused_items = reserve_items;
let applied = self.apply_batch_locked(
&mut state,
&frames,
&mut opened,
&mut unused_bytes,
&mut unused_items,
);
self.release_locked(&mut state, unused_bytes, unused_items, false);
if !applied {
Err(ManagerError::Closed)
} else {
state.last_up_sequence = sequence;
state.last_up_digest = digest;
Ok(sequence)
}
};
if matches!(result, Err(ManagerError::Backpressure)) {
return result;
}
if result.is_err() {
self.close();
for (_, peer_port) in opened {
self.release_stream_reservation(peer_port);
}
return result;
}
for (stream_id, peer_port) in opened {
self.spawn_stream(stream_id, peer_port);
}
if let Some(manager) = self.manager.upgrade() {
manager.record_up(body.len());
}
result
}
pub(super) fn apply_batch_locked(
&self,
state: &mut SessionState,
frames: &[Frame<'_>],
opened: &mut Vec<(u32, u16)>,
unused_bytes: &mut usize,
unused_items: &mut usize,
) -> bool {
for value in frames {
if value.stream_id == 0 {
continue;
}
let was_closed = state.closed_streams.contains(&value.stream_id);
match value.frame_type {
FrameType::Open => {
let Some(peer_port) = self.reserve_stream_locked(state) else {
self.remember_closed_locked(state, value.stream_id);
if !self.queue_control_locked(
state,
FrameType::Close,
value.stream_id,
&[],
) {
return false;
}
continue;
};
state.streams.insert(
value.stream_id,
StreamState {
inbound: VecDeque::new(),
receive_window: frame::INITIAL_STREAM_WINDOW,
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
read_waker: None,
write_waker: None,
},
);
opened.push((value.stream_id, peer_port));
}
FrameType::Data if !was_closed => {
let Some(stream) = state.streams.get_mut(&value.stream_id) else {
return false;
};
stream.receive_window -= value.payload.len() as u32;
stream.inbound.push_back(InboundChunk {
bytes: Bytes::copy_from_slice(value.payload),
offset: 0,
});
*unused_bytes = unused_bytes
.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
*unused_items = unused_items.saturating_sub(1);
if let Some(waker) = stream.read_waker.take() {
waker.wake();
}
}
FrameType::Window if !was_closed => {
let Some(stream) = state.streams.get_mut(&value.stream_id) else {
return false;
};
let amount = frame::window_amount(value.payload).unwrap_or(0);
stream.send_credit = stream
.send_credit
.saturating_add(u64::from(amount))
.min(u64::from(u32::MAX));
if let Some(waker) = stream.write_waker.take() {
waker.wake();
}
}
FrameType::Close if !was_closed => {
let Some(stream) = state.streams.remove(&value.stream_id) else {
return false;
};
let (bytes, items) = inbound_queue_cost(&stream.inbound);
self.release_locked(state, bytes, items, false);
self.remember_closed_locked(state, value.stream_id);
if let Some(waker) = stream.read_waker {
waker.wake();
}
if let Some(waker) = stream.write_waker {
waker.wake();
}
}
FrameType::Data | FrameType::Window | FrameType::Close => {}
_ => return false,
}
}
true
}
fn reserve_stream_locked(&self, state: &mut SessionState) -> Option<u16> {
if state.active_peer_ports.len() >= self.profile.max_streams_per_session {
return None;
}
let manager = self.manager.upgrade()?;
let peer_port = manager.try_acquire_stream(
self.profile_key,
self.profile.max_streams,
self.client_ip,
self.profile.public_addr,
)?;
if state.active_peer_ports.insert(peer_port) {
return Some(peer_port);
}
manager.release_stream(
self.profile_key,
self.client_ip,
self.profile.public_addr,
peer_port,
);
None
}
}
struct UplinkGuard<'a>(&'a AtomicBool);
impl Drop for UplinkGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool {
let mut live = state
.streams
.iter()
.map(|(id, stream)| (*id, (stream.receive_window, stream.send_credit)))
.collect::<HashMap<_, _>>();
let mut closed = HashSet::new();
for value in frames {
if value.stream_id == 0 {
if value.frame_type != FrameType::Pong {
return false;
}
continue;
}
let was_closed = state.closed_streams.contains(&value.stream_id)
|| closed.contains(&value.stream_id);
match value.frame_type {
FrameType::Open => {
if live.contains_key(&value.stream_id) || was_closed {
return false;
}
live.insert(
value.stream_id,
(
frame::INITIAL_STREAM_WINDOW,
u64::from(frame::INITIAL_STREAM_WINDOW),
),
);
}
FrameType::Data if !was_closed => {
let Some((receive_window, send_credit)) = live.get_mut(&value.stream_id) else {
return false;
};
let Ok(payload_len) = u32::try_from(value.payload.len()) else {
return false;
};
if payload_len > *receive_window {
return false;
}
*receive_window -= payload_len;
let _ = send_credit;
}
FrameType::Window if !was_closed => {
let Some((_, send_credit)) = live.get_mut(&value.stream_id) else {
return false;
};
let Ok(amount) = frame::window_amount(value.payload) else {
return false;
};
*send_credit = send_credit
.saturating_add(u64::from(amount))
.min(u64::from(u32::MAX));
}
FrameType::Close if !was_closed => {
if live.remove(&value.stream_id).is_none() {
return false;
}
closed.insert(value.stream_id);
}
FrameType::Data | FrameType::Window | FrameType::Close => {}
_ => return false,
}
}
true
}
pub(super) fn inbound_reservation(
state: &SessionState,
frames: &[Frame<'_>],
) -> (usize, usize) {
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
let mut bytes = 0usize;
let mut items = 0usize;
for value in frames {
match value.frame_type {
FrameType::Open => {
live.insert(value.stream_id);
}
FrameType::Data if live.contains(&value.stream_id) => {
bytes = bytes.saturating_add(value.payload.len() + QUEUE_ITEM_COST);
items = items.saturating_add(1);
}
FrameType::Close => {
live.remove(&value.stream_id);
}
_ => {}
}
}
(bytes, items)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
use crate::config::{
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::WebProcessRuntime;
fn session() -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https,
capability: [0; 32],
max_sessions: 1,
max_streams: 1,
max_streams_per_session: 1,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
profile,
[2; 32],
WebLimitsConfig::default(),
WebTimeoutsConfig::default(),
)
}
#[test]
fn uplink_retry_commits_only_one_exact_body() {
let session = session();
let first = frame::encode(FrameType::Pong, 0, &[1, 2, 3]);
assert_eq!(session.process_up(1, &first), Ok(1));
assert_eq!(session.process_up(1, &first), Ok(1));
let changed = frame::encode(FrameType::Pong, 0, &[1, 2, 4]);
assert_eq!(session.process_up(1, &changed), Err(ManagerError::Protocol));
assert!(session.state.lock().closed);
}
#[test]
fn concurrent_uplink_does_not_commit_sequence() {
let session = session();
let body = frame::encode(FrameType::Pong, 0, &[]);
session.up_active.store(true, Ordering::Release);
assert_eq!(
session.process_up(1, &body),
Err(ManagerError::Concurrent)
);
assert_eq!(session.state.lock().last_up_sequence, 0);
session.up_active.store(false, Ordering::Release);
assert_eq!(session.process_up(1, &body), Ok(1));
}
#[test]
fn backpressured_uplink_does_not_commit_or_close() {
let session = session();
{
let mut state = session.state.lock();
state.streams.insert(
1,
StreamState {
inbound: VecDeque::new(),
receive_window: frame::INITIAL_STREAM_WINDOW,
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
read_waker: None,
write_waker: None,
},
);
state.pending_bytes = session.limits.pending_bytes_per_session;
}
let body = frame::encode(FrameType::Data, 1, &[1]);
assert_eq!(
session.process_up(1, &body),
Err(ManagerError::Backpressure)
);
let state = session.state.lock();
assert!(!state.closed);
assert_eq!(state.last_up_sequence, 0);
assert!(state.streams.get(&1).unwrap().inbound.is_empty());
}
#[test]
fn uplink_gap_is_fatal() {
let session = session();
let body = frame::encode(FrameType::Pong, 0, &[]);
assert_eq!(session.process_up(2, &body), Err(ManagerError::Protocol));
assert!(session.state.lock().closed);
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::sync::futures::OwnedNotified;
use crate::web::session::WebSession;
/// Async byte stream that maps one WEB stream identifier onto carrier frames.
pub(crate) struct WebLogicalStream {
session: Arc<WebSession>,
stream_id: u32,
budget_wait: Option<Pin<Box<OwnedNotified>>>,
}
impl WebLogicalStream {
/// Binds a virtual byte stream to one live carrier stream identifier.
pub(crate) fn new(session: Arc<WebSession>, stream_id: u32) -> Self {
Self {
session,
stream_id,
budget_wait: None,
}
}
}
impl AsyncRead for WebLogicalStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
output: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
self.session.poll_read(self.stream_id, cx, output)
}
}
impl AsyncWrite for WebLogicalStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
input: &[u8],
) -> Poll<io::Result<usize>> {
let result = self.session.poll_write(self.stream_id, cx, input);
if !result.is_pending() {
self.budget_wait = None;
return result;
}
// Register before retrying so a concurrent global-capacity release cannot be lost.
loop {
if self.budget_wait.is_none()
&& let Some(notify) = self.session.budget_notify()
{
self.budget_wait = Some(Box::pin(notify.notified_owned()));
}
let Some(wait) = self.budget_wait.as_mut() else {
break;
};
if wait.as_mut().poll(cx).is_pending() {
break;
}
self.budget_wait = None;
}
match self.session.poll_write(self.stream_id, cx, input) {
Poll::Ready(result) => {
self.budget_wait = None;
Poll::Ready(result)
}
Poll::Pending => Poll::Pending,
}
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}