diff --git a/Cargo.lock b/Cargo.lock index 210b3fe..087c840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2196,9 +2196,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20 0.10.0", "getrandom 0.4.3", @@ -2900,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.5.2" +version = "3.5.3" dependencies = [ "aes", "anyhow", @@ -2916,6 +2916,7 @@ dependencies = [ "ctr", "dashmap", "futures", + "futures-util", "hex", "hmac", "http-body-util", @@ -2933,7 +2934,7 @@ dependencies = [ "num-traits", "parking_lot", "proptest", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest", "rustls", @@ -2950,6 +2951,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-test", + "tokio-tungstenite", "tokio-util", "toml", "tracing", @@ -3147,6 +3149,18 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3339,6 +3353,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "log", + "rand 0.10.2", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 92a3032..fd15199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.5.2" +version = "3.5.3" edition = "2024" [features] @@ -76,6 +76,8 @@ 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" +tokio-tungstenite = { version = "0.30.0", default-features = false } +futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } tokio-rustls = { version = "0.26.4", default-features = false, features = [ "tls12", ] } diff --git a/docs/Architecture/API/API.md b/docs/Architecture/API/API.md index e9944ef..6947c0e 100644 --- a/docs/Architecture/API/API.md +++ b/docs/Architecture/API/API.md @@ -1524,7 +1524,7 @@ The revision is verified again after preparation. With `failure_policy=rollback` ## WEB Proxy Management -The API provides partial operational control for WEB mode; it does not expose a dedicated `/v1/web` resource. +The API provides partial operational control for WEB mode. It does not expose a mutable `/v1/web` resource, but it serves bounded read-only HTML diagnostics at `GET /web-status`. | Operation | Current contract | | --- | --- | @@ -1535,13 +1535,15 @@ The API provides partial operational control for WEB mode; it does not expose a | 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. | +| Read WEB-specific runtime diagnostics | Use authenticated `GET /web-status`; filters cover client IP, process session ID, User-Agent, and non-secret key fingerprint, with optional grouping, expandable HTTP request-to-response details, and WebSocket handshake/message/frame rows. | -`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. +`web.enabled`, `web.carrier`, `web.debug`, `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. +The API whitelist is evaluated against the direct TCP peer and does not use the WEB listener's `X-Forwarded-For` policy. `/web-status` inherits API enablement, whitelist, gray action, and exact authorization-header checks; it accepts only `GET`, normalizes a trailing slash, sets `no-store` and restrictive browser security headers, caps each page at 8 MiB, and permits at most two concurrent renderers. 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. + +`window_secs` defaults to `[web.debug].default_window_secs = 180` and cannot exceed `max_window_secs`. The page can group by any combination of `ip`, `session`, `user_agent`, and `key`. Detail views retain policy-bounded HTTP method, sanitized headers, body, timing, and inner frames from request through response. For `websocket` and `websocket-lanes`, they additionally show the sanitized `GET` to `101` handshake and bounded per-message direction, type, payload/body capture, processing timing, connection/lane identifiers, and parsed inner frames. Raw query credentials, authorization values, WebSocket subprotocols, and session tokens are never retained. Deployment, TLS-terminator examples, links, and WEB-specific verification are documented in the [WEB proxy guide](../../WEB/WEB_PROXY.en.md). diff --git a/docs/Config_params/CONFIG_PARAMS.de.md b/docs/Config_params/CONFIG_PARAMS.de.md index b3508fe..1438933 100644 --- a/docs/Config_params/CONFIG_PARAMS.de.md +++ b/docs/Config_params/CONFIG_PARAMS.de.md @@ -25,6 +25,7 @@ Dieses Dokument listet alle Konfigurationsschlüssel auf, die `config.toml` akze - [server.api](#serverapi) - [server.listeners](#serverlisteners) - [web](#web) + - [web.debug](#webdebug) - [web.limits](#weblimits) - [web.timeouts](#webtimeouts) - [web.vhosts](#webvhosts) @@ -2556,12 +2557,32 @@ Der WEB-Modus transportiert MTProxy-Datenverkehr von Telegram Desktop über HTTP | Schlüssel | Typ | Default | Hot-Reload | | --- | --- | --- | --- | | `enabled` | `bool` | `false` | `✔` | -| `carrier` | `"https"` oder `"https-lanes"` | `"https"` | `✔` | +| `carrier` | `"https"`, `"https-lanes"`, `"websocket"` oder `"websocket-lanes"` | `"https"` | `✔` | +| `debug` | Tabelle | deaktiviert, begrenzte Defaults | `✔` | | `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. +`enabled = true` erfordert mindestens einen durch die Netzwerkrichtlinie zugelassenen WEB-Listener, einen vhost und mindestens ein Profil in jedem vhost. `https` behält den serialisierten HTTPS-Transport bei. Mit `https-lanes` erhalten Stream null und jeder logische Stream eigene Uplink-Sequenzen, Downlink-Cursor, Wiederholungen und Long Polls; dieser Carrier erfordert `max_http_handlers >= 2` und öffentliches HTTP/2 am TLS-Terminator. `websocket` transportiert alle logischen Streams über eine geordnete RFC-6455-Verbindung, während `websocket-lanes` jedem Stream ungleich null eine eigene Verbindung zuweist und Lane-Fehler isoliert. Beide WebSocket-Carrier verwenden nach der HTTPS-Sitzungserstellung `GET /api/v1/ws` und erfordern, dass der TLS-Terminator die HTTP/1.1-Upgrade-Header unverändert weiterleitet. 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.debug] + +Diese hot-reload-fähige Tabelle steuert den prozesseigenen serverseitigen WEB-Debug-Recorder, der am API-Listener als authentifiziertes HTML unter `GET /web-status` bereitgestellt wird. Die Erfassung ist standardmäßig deaktiviert. Gespeicherte und in Verarbeitung befindliche Datensätze bleiben durch die nur nach einem Neustart änderbaren Werte in `[web.limits]` begrenzt. + +| Schlüssel | Typ | Default | Beschreibung | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Aktiviert WEB-HTTP-, WebSocket-Message-, Frame- und Lifecycle-Debugdatensätze. | +| `capture_lifecycle` | `bool` | `true` | Zeichnet typisierte Bridge-, Sitzungs-, Stream-, Handshake-, Relay- und Close-Ereignisse auf. | +| `capture_headers` | `bool` | `true` | Speichert Headernamen und nur ausdrücklich zugelassene Werte ohne Zugangsdaten. | +| `capture_timings` | `bool` | `true` | Speichert Zeitpunkte für Request-Body, fertige Response, Response-Body und WebSocket-Message-Verarbeitung. | +| `capture_frames` | `bool` | `true` | Zerlegt begrenzte Carrier-Bodys in Frame-Typ, Stream-ID, Länge, WINDOW- und Fehlermetadaten, ohne die Frame-Nutzlast zusätzlich zu speichern. | +| `body_capture` | `"off"`, `"metadata"`, `"prefix"` oder `"full"` | `"metadata"` | Steuert die Speicherung von Bytes aus Request- und Response-Bodys. | +| `body_prefix_bytes` | `usize` | `4096` | In `prefix` gespeicherter Präfix für erkannte WEB-Bodys. | +| `decoy_body_prefix_bytes` | `usize` | `4096` | Maximal gespeicherter Präfix für gewöhnlichen Decoy-Verkehr in `prefix` und `full`. | +| `default_window_secs` | `u64` | `180` | Standard-Beobachtungsfenster von `/web-status`. | +| `max_window_secs` | `u64` | `3600` | Größtes von `/web-status` akzeptiertes Beobachtungsfenster; validiert auf höchstens 86400. | + +Eine Änderung von `enabled` oder einem Erfassungsfeld löscht gespeicherte Datensätze und verwirft Commits, die unter der vorherigen Policy-Epoche begonnen wurden. Ändert sich nur das standardmäßige oder maximale Beobachtungsfenster, bleiben kompatible Datensätze erhalten. `full` speichert den vollständigen Body eines erkannten Carriers nur bis `web.limits.max_body_bytes`; Decoy-Bodys bleiben immer auf einen Präfix begrenzt. Ein Präfix, der nur mit einer gleichzeitig erhöhten, neustartpflichtigen Kapazität zulässig wäre, wird zusammen mit `web.debug` bis zum Neustart zurückgestellt. URI-Queries werden nie gespeichert, Werte von Credential-Headern werden ausgelassen, Body-Kopien werden von bekannten WEB-Capabilities und Bearer-Tokens bereinigt und Profilschlüssel ausschließlich als domänengetrennter Fingerprint mit 16 Hex-Zeichen dargestellt. # [web.limits] @@ -2576,6 +2597,10 @@ Diese prozessweiten Obergrenzen begrenzen alle WEB-Register, Warteschlangen, Req | `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. | +| `websocket_bytes_global` | `usize` | `268435456` | Transientes Teilbudget für WebSocket-Codecs, Messages und Write-Staging innerhalb von `pending_bytes_global`. | +| `websocket_admission_watermark_pct` | `u8` | `75` | WebSocket-Byte-Anteil, ab dem neue Admission eine Owner-First-Verbindung ersetzen darf. | +| `websocket_eviction_watermark_pct` | `u8` | `90` | WebSocket-Byte-Anteil, ab dem Queue-Druck die zulässige Verbindung mit dem ältesten Fortschritt verdrängen darf. | +| `websocket_http_connection_reserve` | `usize` | `64` | Für WebSocket-Upgrades gesperrte HTTP-Verbindungen, die Kapazität für gewöhnliches HTTP und Decoys erhalten. | | `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. | @@ -2597,7 +2622,9 @@ Diese prozessweiten Obergrenzen begrenzen alle WEB-Register, Warteschlangen, Req | `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. | +| `debug_records_capacity` | `usize` | `65536` | Maximale Zahl gespeicherter WEB-Debugdatensätze. | +| `debug_bytes_global` | `usize` | `67108864` | Globale Byte-Obergrenze für gespeicherte und in Verarbeitung befindliche WEB-Debugdaten; mindestens 4096. | +| `memory_envelope_bytes` | `usize` | `805306368` | Deklarierter Rahmen für HTTP-Heads, Bodys, gemeinsame Queues/WebSocket-I/O, statische Snapshots und begrenzte Debug-/Statuspuffer; 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. | @@ -2615,6 +2642,9 @@ Alle Timeouts werden in Sekunden angegeben und müssen im Bereich `1..=3600` lie | `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. | +| `websocket_write_secs` | `u64` | `30` | `✔` | Maximale Wartezeit für einen WebSocket-Write oder Flush. | +| `websocket_backpressure_secs` | `u64` | `30` | `✔` | Maximale Wartezeit auf Fortschritt des gemeinsamen Byte-Budgets oder einer Queue, bevor die betroffene Verbindung geschlossen wird. | +| `websocket_eviction_secs` | `u64` | `1` | `✔` | Karenzzeit, in der ein verdrängter WebSocket Slot und Budget freigeben muss, bevor Admission fehlschlägt. | | `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. | @@ -2655,9 +2685,9 @@ Profilgrenzen müssen ungleich null sein und dürfen die zugehörigen globalen G ## 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. +- Config-Watcher und Generations-Reload wenden `web.enabled`, `web.carrier`, `web.debug`, `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. +- Es gibt keine veränderbare Ressource `/v1/web`. `GET /web-status` stellt authentifizierte, schreibgeschützte HTML-Diagnosen bereit; `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. diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index a5c0dd3..9fba001 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -25,6 +25,7 @@ This document lists all configuration keys accepted by `config.toml`. - [server.api](#serverapi) - [server.listeners](#serverlisteners) - [web](#web) + - [web.debug](#webdebug) - [web.limits](#weblimits) - [web.timeouts](#webtimeouts) - [web.vhosts](#webvhosts) @@ -2556,12 +2557,32 @@ WEB mode carries Telegram Desktop MTProxy traffic through HTTPS terminated by an | Key | Type | Default | Hot-Reload | | --- | --- | --- | --- | | `enabled` | `bool` | `false` | `✔` | -| `carrier` | `"https"` or `"https-lanes"` | `"https"` | `✔` | +| `carrier` | `"https"`, `"https-lanes"`, `"websocket"`, or `"websocket-lanes"` | `"https"` | `✔` | +| `debug` | table | disabled, bounded defaults | `✔` | | `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. +`enabled = true` requires at least one network-eligible WEB listener, at least one vhost, and at least one profile in every vhost. `https` preserves the serialized HTTPS transport. `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. `websocket` carries all logical streams over one ordered RFC 6455 connection, while `websocket-lanes` owns one connection per non-zero logical stream and isolates lane failures. Both WebSocket carriers use `GET /api/v1/ws` after HTTPS session creation and require the TLS terminator to preserve HTTP/1.1 Upgrade headers. 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.debug] + +This hot-reloadable table controls the process-owned server-side WEB debug recorder exposed as authenticated HTML at `GET /web-status` on the API listener. Collection is disabled by default. Retained and in-flight records remain bounded by restart-only values in `[web.limits]`. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Enables WEB HTTP, WebSocket-message, frame, and lifecycle debug records. | +| `capture_lifecycle` | `bool` | `true` | Records typed bridge, session, stream, handshake, relay, and close events. | +| `capture_headers` | `bool` | `true` | Retains header names and only allowlisted non-credential values. | +| `capture_timings` | `bool` | `true` | Retains request-body, response-ready, response-body, and WebSocket message-processing timing points. | +| `capture_frames` | `bool` | `true` | Parses bounded carrier bodies into frame type, stream ID, length, WINDOW, and error metadata without retaining frame payload separately. | +| `body_capture` | `"off"`, `"metadata"`, `"prefix"`, or `"full"` | `"metadata"` | Controls request and response body byte retention. | +| `body_prefix_bytes` | `usize` | `4096` | Prefix retained for recognized WEB bodies in `prefix` mode. | +| `decoy_body_prefix_bytes` | `usize` | `4096` | Maximum retained prefix for ordinary decoy traffic in both `prefix` and `full` modes. | +| `default_window_secs` | `u64` | `180` | Default observation window rendered by `/web-status`. | +| `max_window_secs` | `u64` | `3600` | Largest observation window accepted by `/web-status`; validated at no more than 86400. | + +Changing `enabled` or any capture field clears retained records and rejects commits started under the previous policy epoch. Changing only the default or maximum observation window preserves compatible retained records. `full` retains a complete recognized carrier body only up to `web.limits.max_body_bytes`; decoy bodies always remain prefix-bounded. A prefix that depends on a simultaneously increased restart-only capacity is deferred with `web.debug` until restart. URI queries are never retained, credential header values are omitted, body copies are scrubbed for known WEB capabilities and bearer tokens, and profile keys are represented only by a domain-separated 16-hex fingerprint. # [web.limits] @@ -2576,6 +2597,10 @@ These process-wide ceilings make every WEB registry, queue, request body, static | `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. | +| `websocket_bytes_global` | `usize` | `268435456` | Transient WebSocket codec, message, and write-staging sub-budget inside `pending_bytes_global`. | +| `websocket_admission_watermark_pct` | `u8` | `75` | WebSocket byte percentage at which new admission may replace an owner-first victim. | +| `websocket_eviction_watermark_pct` | `u8` | `90` | WebSocket byte percentage at which queue pressure may evict the least-recently-progressed eligible connection. | +| `websocket_http_connection_reserve` | `usize` | `64` | Accepted HTTP connections unavailable to WebSocket upgrades, preserving ordinary HTTP and decoy capacity. | | `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. | @@ -2597,7 +2622,9 @@ These process-wide ceilings make every WEB registry, queue, request body, static | `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. | +| `debug_records_capacity` | `usize` | `65536` | Maximum retained WEB debug record count. | +| `debug_bytes_global` | `usize` | `67108864` | Retained plus in-flight WEB debug byte ceiling; minimum 4096. | +| `memory_envelope_bytes` | `usize` | `805306368` | Declared envelope for HTTP heads, bodies, shared queues/WebSocket I/O, static snapshots, and bounded debug/status buffers; 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. | @@ -2615,6 +2642,9 @@ Every timeout is measured in seconds and must be within `1..=3600`. The longest | `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. | +| `websocket_write_secs` | `u64` | `30` | `✔` | Maximum wait for one WebSocket write or flush. | +| `websocket_backpressure_secs` | `u64` | `30` | `✔` | Maximum wait for shared byte-budget or queue progress before closing the affected connection. | +| `websocket_eviction_secs` | `u64` | `1` | `✔` | Grace allowed for a pressure-evicted WebSocket to release its slot and budget before admission fails. | | `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. | @@ -2655,9 +2685,9 @@ Profile limits must be non-zero and no greater than their corresponding global l ## 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. +- The config watcher and generation reload apply `web.enabled`, `web.carrier`, `web.debug`, `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`. +- There is no mutable `/v1/web` resource. `GET /web-status` provides authenticated read-only HTML diagnostics; `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. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index a9da3bd..97e22e1 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -24,6 +24,7 @@ - [server.api](#serverapi) - [server.listeners](#serverlisteners) - [web](#web) + - [web.debug](#webdebug) - [web.limits](#weblimits) - [web.timeouts](#webtimeouts) - [web.vhosts](#webvhosts) @@ -2482,12 +2483,32 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут | Ключ | Тип | По умолчанию | Hot-Reload | | --- | --- | --- | --- | | `enabled` | `bool` | `false` | `✔` | -| `carrier` | `"https"` или `"https-lanes"` | `"https"` | `✔` | +| `carrier` | `"https"`, `"https-lanes"`, `"websocket"` или `"websocket-lanes"` | `"https"` | `✔` | +| `debug` | таблица | выключено, ограниченные defaults | `✔` | | `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. +Для `enabled = true` нужен как минимум один доступный по сетевой политике WEB-listener, один vhost и один профиль в каждом vhost. `https` сохраняет сериализованный HTTPS transport. В `https-lanes` stream zero и каждый logical stream получают независимые uplink sequence, downlink cursor, retry и long poll; carrier требует `max_http_handlers >= 2` и публичного HTTP/2 на TLS-терминаторе. `websocket` переносит все logical streams через одно упорядоченное RFC 6455 connection, а `websocket-lanes` выделяет отдельное connection каждому ненулевому stream и изолирует сбои lane. Оба WebSocket carrier используют `GET /api/v1/ws` после создания HTTPS-сессии и требуют от TLS-терминатора сохранять HTTP/1.1 Upgrade headers. Reload применяет `carrier` только к новым bridge sessions. Отключение WEB после reload прекращает выдачу новых bridge- и session-credentials; для отзыва активных сессий отдельного пользователя используйте users API. + +# [web.debug] + +Эта hot-reloadable таблица управляет process-owned серверным WEB debug recorder, доступным на API-listener’е как аутентифицированный HTML по `GET /web-status`. Сбор по умолчанию отключён. Сохранённые и находящиеся в обработке записи ограничены значениями из `[web.limits]`, изменение которых требует перезапуска. + +| Ключ | Тип | По умолчанию | Описание | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Включает WEB HTTP, WebSocket-message, frame и lifecycle debug records. | +| `capture_lifecycle` | `bool` | `true` | Записывает типизированные события bridge, session, stream, handshake, relay и close. | +| `capture_headers` | `bool` | `true` | Сохраняет имена headers и только разрешённые значения без credentials. | +| `capture_timings` | `bool` | `true` | Сохраняет timing points для request body, готового response, response body и обработки WebSocket messages. | +| `capture_frames` | `bool` | `true` | Разбирает bounded carrier bodies в тип frame, stream ID, длину, WINDOW и метаданные ошибок, не сохраняя frame payload отдельно. | +| `body_capture` | `"off"`, `"metadata"`, `"prefix"` или `"full"` | `"metadata"` | Управляет сохранением байтов request и response body. | +| `body_prefix_bytes` | `usize` | `4096` | Prefix распознанного WEB body, сохраняемый в режиме `prefix`. | +| `decoy_body_prefix_bytes` | `usize` | `4096` | Максимальный сохраняемый prefix обычного decoy-трафика в режимах `prefix` и `full`. | +| `default_window_secs` | `u64` | `180` | Стандартное окно наблюдения, отображаемое `/web-status`. | +| `max_window_secs` | `u64` | `3600` | Максимальное окно наблюдения, принимаемое `/web-status`; при валидации ограничено значением 86400. | + +Изменение `enabled` или любого поля capture очищает сохранённые записи и отклоняет commits, начатые в предыдущую policy epoch. Изменение только стандартного или максимального окна наблюдения сохраняет совместимые записи. `full` сохраняет полное тело распознанного carrier только до `web.limits.max_body_bytes`; decoy bodies всегда остаются ограничены настроенным prefix. Prefix, который помещается только в одновременно увеличенную restart-only ёмкость, откладывается вместе с `web.debug` до перезапуска. URI queries никогда не сохраняются, значения credential headers исключаются, копии body очищаются от известных WEB capabilities и bearer tokens, а ключи профилей представлены только domain-separated fingerprint из 16 hex-символов. # [web.limits] @@ -2502,6 +2523,10 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут | `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. | +| `websocket_bytes_global` | `usize` | `268435456` | Подбюджет transient WebSocket codec, messages и write staging внутри `pending_bytes_global`. | +| `websocket_admission_watermark_pct` | `u8` | `75` | Доля WebSocket byte-budget, после которой новый admission может вытеснить owner-first victim. | +| `websocket_eviction_watermark_pct` | `u8` | `90` | Доля WebSocket byte-budget, после которой queue pressure может вытеснить подходящее connection с наиболее старым прогрессом. | +| `websocket_http_connection_reserve` | `usize` | `64` | Число принятых HTTP connections, недоступных WebSocket upgrades и сохраняющих capacity для обычного HTTP и decoy. | | `max_body_readers` | `usize` | `32` | Одновременно собираемые request bodies на весь процесс. | | `max_body_bytes_global` | `usize` | `67108864` | Глобальный байтовый резерв для собранных bodies. | | `max_sessions_global` | `usize` | `128` | Активные WEB-сессии на весь процесс. | @@ -2523,7 +2548,9 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут | `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. | +| `debug_records_capacity` | `usize` | `65536` | Максимальное число сохранённых WEB debug records. | +| `debug_bytes_global` | `usize` | `67108864` | Глобальная байтовая граница сохранённых и находящихся в обработке WEB debug данных; минимум 4096. | +| `memory_envelope_bytes` | `usize` | `805306368` | Заявленный envelope для HTTP heads, bodies, общих queues/WebSocket I/O, static snapshots и bounded debug/status buffers; максимум 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 скорость создания сессий. | @@ -2541,6 +2568,9 @@ WEB-режим переносит MTProxy-трафик Telegram Desktop внут | `body_secs` | `u64` | `30` | `✔` | Сбор одного аутентифицированного carrier body. | | `stream_handshake_secs` | `u64` | `10` | `✔` | Выполнение внутреннего MTProxy handshake. | | `long_poll_secs` | `u64` | `25` | `✔` | Максимальная длительность пустого downlink long poll. | +| `websocket_write_secs` | `u64` | `30` | `✔` | Максимальное ожидание одной WebSocket write или flush операции. | +| `websocket_backpressure_secs` | `u64` | `30` | `✔` | Максимальное ожидание прогресса общего byte-budget или queue перед закрытием затронутого connection. | +| `websocket_eviction_secs` | `u64` | `1` | `✔` | Grace period для освобождения slot и budget вытесненным WebSocket до отказа admission. | | `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. | @@ -2581,9 +2611,9 @@ Hostname нормализуется при валидации и должен п ## Lifecycle WEB и управление через API -- Config watcher и generation reload применяют `web.enabled`, `web.carrier`, `web.timeouts`, vhosts, profiles и decoy snapshots без перезапуска процесса. Существующие сессии сохраняют carrier, лимиты и deadlines своего момента создания; новые bridge sessions используют активное поколение. +- Config watcher и generation reload применяют `web.enabled`, `web.carrier`, `web.debug`, `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`. +- Изменяемого ресурса `/v1/web` нет. `GET /web-status` предоставляет аутентифицированную read-only HTML-диагностику; `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 активируется только после перезапуска процесса. diff --git a/docs/WEB/WEB_PROXY.de.md b/docs/WEB/WEB_PROXY.de.md index 9918d5c..22b8498 100644 --- a/docs/WEB/WEB_PROXY.de.md +++ b/docs/WEB/WEB_PROXY.de.md @@ -2,7 +2,7 @@ [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. +Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS- oder WebSocket-Carrier, die mit dem Proxy-Typ `WEB` von Telegram Desktop kompatibel sind. Telemt terminiert TLS nicht selbst: NGINX oder HAProxy verwaltet das öffentliche Zertifikat und leitet unverschlüsseltes HTTP/1.1 an einen privaten Telemt-Listener weiter. > [!IMPORTANT] > @@ -12,7 +12,7 @@ Der WEB-Modus transportiert gewöhnliche MTProxy-Streams über begrenzte HTTPS-C ```text Telegram Desktop - | HTTPS :443 + | HTTPS oder WSS :443 v NGINX oder HAProxy (TLS-Terminierung, kanonischer Host und eine X-Forwarded-For-Adresse) | unverschlüsseltes HTTP/1.1 in einem privaten Netz @@ -28,7 +28,7 @@ Leiten Sie den vollständigen öffentlichen vhost an Telemt weiter. Wenn der TLS - 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. +- `web.carrier = "https"` wählt serialisierte HTTPS-Uplinks und Long Polling. `https-lanes` wählt unabhängige HTTPS-Sequenzen und Polls pro logischem Stream. `websocket` wählt einen geordneten WebSocket für alle Streams. `websocket-lanes` wählt einen unabhängig verwalteten WebSocket für jeden logischen Stream ungleich null. - Capability-, Bootstrap- und Session-Zugangsdaten sind getrennte Werte mit begrenzter Lebensdauer. Carrier-Zugangsdaten sind geheim und dürfen nicht in Access-Logs erscheinen. - Ein Bootstrap ist ein Bearer-Token und nicht an eine Quelladresse gebunden. Client-Adresse und IP-Familie dürfen sich zwischen dem Laden der Bridge und der Sitzungserstellung ändern. Die Ausstellungsadresse bleibt dem Limit ungenutzter Bootstraps zugeordnet; die Adresse des ersten gültigen Erstellungs-Requests wird der Sitzung zugeordnet. - Die innere MTProxy-Authentifizierung ist auf den Benutzer und Secret-Modus des vhost-Profils beschränkt. Ein ungültiger innerer Handshake schließt nur seinen logischen Stream und gelangt niemals in den TCP-Masking-Pfad. @@ -99,6 +99,12 @@ Alle Lane-Queues bleiben innerhalb der vorhandenen Byte-/Item-Budgets pro Sitzun 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. +Beide WebSocket-Carrier erstellen und löschen die übergeordnete Sitzung weiterhin über HTTPS und verwenden danach einen strikten Upgrade-Request ohne Body an `GET /api/v1/ws`. `websocket` übermittelt in `Sec-WebSocket-Protocol` exakt `tproxy-v1.`; binäre Messages sind geordnete Carrier-Batches, und ein Protokoll-, Deadline- oder Verbindungsfehler schließt die gesamte übergeordnete Sitzung. `websocket-lanes` übermittelt exakt `tproxy-lane-v1..`, wobei die Stream-ID kanonisch dezimal im Bereich `1..=16777215` steht. Die erste binäre Message muss mit `OPEN` beginnen, alle Frames müssen diese Stream-ID verwenden und ein Fehler nach dem Upgrade schließt nur diese Lane. Es gibt keinen Lane-null-WebSocket: HTTPS transportiert `HELLO` und `WELCOME`, während RFC-6455-Ping/Pong die Verbindungsliveness gewährleistet. + +WebSocket-Codec-Puffer und laufende Read-/Write-Messages teilen das prozesseigene Budget `pending_bytes_global` mit den Carrier-Queues und sind zusätzlich durch `websocket_bytes_global` begrenzt. Admission reserviert `websocket_http_connection_reserve` angenommene Verbindungen für gewöhnliches HTTP und Decoys. Unter Druck erfolgt die Verdrängung zuerst beim selben Owner und danach nach dem ältesten Fortschritt; Pre-Upgrade- und tote Verbindungen stehen vor aktiven Lanes und multiplexierten Sitzungen. Nach `long_poll_secs` ohne Peer-Aktivität wird auch bei kontinuierlichem Downlink-Verkehr ein Transport-Ping gesendet; fehlende Peer-Aktivität während des doppelten, beim Verbindungsaufbau festgelegten Intervalls macht die Verbindung zum Cleanup-Kandidaten. + +Jeder Authentifizierungs-, Shape-, Lane-Reservierungs- oder Kapazitätsfehler vor dem Upgrade folgt dem bereinigten Decoy-Pfad und legt keinen WebSocket-spezifischen Status offen. Das exakte Subprotokoll enthält den Session-Bearer und darf nicht protokolliert werden. + 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. @@ -119,6 +125,11 @@ Alle WEB-Schlüssel und Defaults sind in der [Konfigurationsreferenz](../Config_ ## TLS-Terminierung mit NGINX ```nginx +map $http_upgrade $telemt_connection_upgrade { + default upgrade; + '' ''; +} + upstream telemt_web { server 127.0.0.1:18080; keepalive 64; @@ -140,11 +151,12 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header Connection ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $telemt_connection_upgrade; proxy_connect_timeout 5s; - proxy_send_timeout 35s; - proxy_read_timeout 35s; + proxy_send_timeout 65s; + proxy_read_timeout 65s; proxy_request_buffering off; proxy_buffering off; proxy_next_upstream off; @@ -152,9 +164,9 @@ server { } ``` -`client_max_body_size` muss mindestens `web.limits.max_body_bytes` entsprechen. `proxy_read_timeout` und `proxy_send_timeout` müssen größer als `web.timeouts.long_poll_secs` sein, dessen Default 25 Sekunden beträgt. Überschreiben Sie `X-Forwarded-For`, statt einen Wert anzuhängen. Telemt akzeptiert eine syntaktisch gültige IP-Adresse; fehlt der Header bei einem vertrauenswürdigen TLS-Terminator, verwendet Telemt die Adresse des direkten Peers, doch clientbezogene Limits und Quellrichtlinien sehen dann den Terminator statt des echten Clients. Aktivieren Sie keine Upstream-Wiederholungen: Der Bridge-Transport führt byte-identische Wiederholungen über sein eigenes Sequenzprotokoll aus. +Platzieren Sie `map` im NGINX-Kontext `http`. `client_max_body_size` muss mindestens `web.limits.max_body_bytes` entsprechen. Read-, Send- und Client-Timeouts müssen sowohl den standardmäßigen 25-Sekunden-Long-Poll als auch das doppelte WebSocket-Liveness-Intervall überschreiten; 65 Sekunden decken die Defaults ab. Überschreiben Sie `X-Forwarded-For`, statt einen Wert anzuhängen. Telemt akzeptiert eine syntaktisch gültige IP-Adresse; fehlt der Header bei einem vertrauenswürdigen TLS-Terminator, verwendet Telemt die Adresse des direkten Peers, doch clientbezogene Limits und Quellrichtlinien sehen dann den Terminator statt des echten Clients. Aktivieren Sie keine Upstream-Wiederholungen: Die Bridge führt byte-identische HTTPS-Wiederholungen aus, ein etablierter WebSocket wird jedoch nie transparent wiederholt. -Öffentliches HTTP/2 ist für `https-lanes` obligatorisch; verwenden Sie die entsprechende HTTP/2-Direktive der installierten NGINX-Version. Der private Hop von NGINX zu Telemt bleibt absichtlich HTTP/1.1. Die Upstream-Verbindungskapazität muss die erwarteten gleichzeitigen Lane-Polls tragen; `keepalive` steuert den Idle-Pool und ist keine Nebenläufigkeitsgrenze. +Öffentliches HTTP/2 ist für `https-lanes` obligatorisch; verwenden Sie die entsprechende HTTP/2-Direktive der installierten NGINX-Version. WebSocket-Upgrade erfordert HTTP/1.1, daher muss der öffentliche Endpunkt auch HTTP/1.1 zulassen und der private Hop von NGINX zu Telemt bleibt HTTP/1.1. Bewahren Sie `Connection`, `Upgrade` und `Sec-WebSocket-*` wie gezeigt unverändert. Die Upstream-Verbindungskapazität muss die erwarteten gleichzeitigen Lane-Polls oder WebSocket-Lanes tragen; `keepalive` steuert den Idle-Pool und ist keine Nebenläufigkeitsgrenze. ## TLS-Terminierung mit HAProxy @@ -171,14 +183,14 @@ backend telemt_web option http-keep-alive retries 0 timeout connect 5s - timeout server 35s + timeout server 65s 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. +Im Frontend oder im Abschnitt `defaults` muss für das standardmäßige WebSocket-Liveness-Intervall auch `timeout client 65s` oder länger gesetzt sein. Für `https-lanes` muss das öffentliche HAProxy-ALPN `h2`, für WebSocket-Upgrade außerdem `http/1.1` enthalten. Bewahren Sie `Connection`, `Upgrade` und `Sec-WebSocket-*` unverändert; 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 @@ -186,21 +198,22 @@ Im Frontend oder im Abschnitt `defaults` muss auch `timeout client` oberhalb der | --- | --- | | 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. | +| `web.enabled`, `web.carrier`, `web.debug`, 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 Session-Deadlines ihres Erstellungszeitpunkts; neu ausgegebene Bridge-Sitzungen verwenden den aktiven Carrier. WebSocket-Write-, Backpressure- und Eviction-Vorgänge lesen die aktiven hot-reload-fähigen Deadlines. 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. +API-Verwaltung ist verfügbar, aber absichtlich eingeschränkt. Es gibt keine veränderbare Ressource `/v1/web`; der API-Listener stellt die schreibgeschützte HTML-Debug-Ansicht unter `/web-status` bereit. | 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. | +| Begrenzte serverseitige WEB-Request- und Lifecycle-Details untersuchen | Ja, über ein authentifiziertes `GET /web-status`. | | `[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. | @@ -217,6 +230,30 @@ 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. +### Serverseitige WEB-Debug-Ansicht + +Aktivieren Sie die begrenzte Erfassung in der zuständigen Konfigurationsdatei: + +```toml +[web.debug] +enabled = true +capture_lifecycle = true +capture_headers = true +capture_timings = true +capture_frames = true +body_capture = "metadata" +body_prefix_bytes = 4096 +decoy_body_prefix_bytes = 4096 +default_window_secs = 180 +max_window_secs = 3600 +``` + +Öffnen Sie `http://127.0.0.1:9091/web-status` mit derselben Whitelist direkter Peers und demselben exakten `Authorization`-Header wie für die API. Ein abschließender Slash wird akzeptiert. Nur `GET` ist zulässig. Die Seite unterstützt die Filter `window_secs`, kanonische `ip`, numerische `session`, `user_agent` ohne Beachtung der Groß-/Kleinschreibung und `key`. Wiederholen Sie `group_by=ip`, `group_by=session`, `group_by=user_agent` oder `group_by=key`, um gruppierte Zusammenfassungen zu erstellen; `limit` ist auf `1..=1000` beschränkt. HTTP-Zeilen lassen sich vom Request bis zur Response zu Methode, Pfad, bereinigten Headern, Body-Metadaten oder -Bytes, Zeitpunkten, Frames und typisierten Lifecycle-Ereignissen aufklappen. Für WebSocket kommen der bereinigte Handshake `GET` → `101` sowie begrenzte Angaben pro Message zu Richtung, Message-Typ, Payload-/Body-Erfassung, Verarbeitungszeit, Verbindungs-/Lane-ID und geparsten inneren Frames hinzu. Rohe Subprotokolle und Session-Tokens werden nie gespeichert. + +Der prozesseigene Ring übersteht den Austausch einer Runtime-Generation. Änderungen der Erfassungs-Policy löschen inkompatible gespeicherte Datensätze; reine Änderungen des Beobachtungsfensters tun dies nicht. Der Ring ist standardmäßig auf 65536 Datensätze und 64 MiB gespeicherte plus in Verarbeitung befindliche Daten begrenzt, die HTML-Response auf 8 MiB und die Gruppierung auf 1024 Gruppen; gleichzeitig dürfen höchstens zwei Response-Bodys Seiten-Permits halten. Ändern Sie `web.limits.debug_records_capacity` oder `web.limits.debug_bytes_global` nur zusammen mit einem Prozessneustart. Ein hot-reload-fähiger Präfix, der nur in eine gleichzeitig erhöhte neustartpflichtige Kapazität passt, wird bis zu diesem Neustart zurückgestellt. + +`body_capture = "off"` lässt Bodys aus, `metadata` speichert Längen und Endzustände, `prefix` die konfigurierten Präfixe und `full` erkannte Carrier-Bodys bis `web.limits.max_body_bytes`. Gewöhnliche Decoy-Bodys bleiben auch in `full` auf `decoy_body_prefix_bytes` begrenzt. Queries und rohe Capabilities werden nie gespeichert; Werte von Credential-Headern werden ausgelassen; bekannte WEB-Capabilities und Bearer-Tokens werden aus erfassten Bodys entfernt; der angezeigte Schlüssel ist ein nicht geheimer, domänengetrennter Fingerprint. Die Zeitmessung endet beim Polling des Hyper-Bodys und behauptet weder einen Kernel-Flush noch eine TCP-Bestätigung. + 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 @@ -264,8 +301,9 @@ Der vollständige Vertrag für Requests, Revisionen, Fehler und alle Benutzer-En 3. Prüfen Sie, dass Telemt genau eine syntaktisch gültige `X-Forwarded-For`-Adresse und `Host: proxy.example.com` oder `Host: proxy.example.com:443` erhält. 4. Importieren Sie den ausgegebenen `tg://webproxy`-Link in den vorgesehenen Telegram-Desktop-Build und stellen Sie eine Proxy-Verbindung her. 5. Bestätigen Sie für `https-lanes`, dass die öffentliche Verbindung HTTP/2 ausgehandelt hat, und testen Sie mindestens zwei gleichzeitige logische Streams; der private Hop zu Telemt bleibt HTTP/1.1. -6. Testen Sie einen Reconnect und mindestens einen Long Poll über 25 Sekunden, um sicherzustellen, dass Frontend-Timeouts den Carrier nicht abbrechen. -7. Prüfen Sie Benutzer- und logische MTProxy-Verbindungslimits anhand der Logical-Stream-Zähler und nicht anhand der Zahl der HTTP-Verbindungen. +6. Bestätigen Sie für `websocket` eine `101`-Response, binären Relay-Datenverkehr und RFC-6455-Ping/Pong nach 25 Sekunden. Testen Sie für `websocket-lanes` mindestens zwei gleichzeitige Stream-Sockets und prüfen Sie, dass das Schließen oder Beschädigen einer Lane weder Geschwister noch die übergeordnete Sitzung schließt. +7. Testen Sie einen Reconnect und mindestens einen Long Poll über 25 Sekunden, um sicherzustellen, dass Frontend-Timeouts den Carrier nicht abbrechen. +8. Prüfen Sie Benutzer- und logische MTProxy-Verbindungslimits anhand der Logical-Stream-Zähler und nicht anhand der Zahl der HTTP-Verbindungen. ## Fehlerbehebung @@ -274,6 +312,9 @@ Der vollständige Vertrag für Requests, Revisionen, Fehler und alle Benutzer-En | 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 syntaktisch gültigen `X-Forwarded-For`-Wert. | | Long Polls werden nach einem festen Intervall getrennt | Setzen Sie Client-, Server-, Sende- und Lese-Timeouts von NGINX/HAProxy über `web.timeouts.long_poll_secs`. | +| WebSocket-Upgrade erreicht statt `101` den Decoy | Bewahren Sie HTTP/1.1 `Connection: Upgrade`, `Upgrade: websocket`, das einzelne exakte `Sec-WebSocket-Protocol` und den kanonischen bodylosen Request `/api/v1/ws`. Prüfen Sie außerdem Carrier-/Session-Kompatibilität und die Prozess-Verbindungsreserve. | +| Ein `websocket-lanes`-Stream wurde geschlossen, Geschwister bleiben aber verbunden | Dies ist die beabsichtigte Fehlergrenze. Prüfen Sie die Message-/Frame-Zeilen dieser Lane in `/web-status`; fehlerhafte oder lane-fremde Frames, Write-Timeouts und Backend-Close schließen nur die betroffene Lane. | +| `/web-status` ist leer | Prüfen Sie, dass `[web.debug].enabled = true` gesetzt ist, wenden Sie die Konfiguration an, wählen Sie ein Fenster innerhalb von `max_window_secs` und erzeugen Sie nach der Policy-Änderung neuen WEB-Datenverkehr. | | `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. | diff --git a/docs/WEB/WEB_PROXY.en.md b/docs/WEB/WEB_PROXY.en.md index 1bf9592..608c681 100644 --- a/docs/WEB/WEB_PROXY.en.md +++ b/docs/WEB/WEB_PROXY.en.md @@ -2,7 +2,7 @@ [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. +WEB mode carries ordinary MTProxy streams through bounded HTTPS or WebSocket carriers compatible with Telegram Desktop's `WEB` proxy type. Telemt does not terminate TLS: NGINX or HAProxy owns the public certificate and forwards plain HTTP/1.1 to a private Telemt listener. > [!IMPORTANT] > @@ -12,7 +12,7 @@ WEB mode carries ordinary MTProxy streams through bounded HTTPS carriers compati ```text Telegram Desktop - | HTTPS :443 + | HTTPS or WSS :443 v NGINX or HAProxy (TLS termination, canonical Host and one X-Forwarded-For address) | plain HTTP/1.1 on a private network @@ -28,7 +28,7 @@ Route the complete public vhost to Telemt. Splitting only recognized carrier pat - 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. +- `web.carrier = "https"` selects serialized HTTPS uplink and long polling. `https-lanes` selects independent HTTPS sequencing and polling per logical stream. `websocket` selects one ordered WebSocket for all streams. `websocket-lanes` selects one independently owned WebSocket per non-zero logical stream. - Capability, bootstrap, and session credentials are separate bounded-lifetime values. Carrier credentials must be treated as secrets and must not appear in access logs. - A bootstrap is a bearer credential, not a source-address-bound token. The client address and IP family may change between bridge loading and session creation. The issuing address retains unused-bootstrap accounting, while the address on the first valid creation request owns the session. - Inner MTProxy authentication is restricted to the user and secret mode selected by the vhost profile. Invalid inner handshakes close only their logical stream and never enter the TCP masking path. @@ -99,6 +99,12 @@ All lane queues remain inside the existing per-session and process-wide byte/ite 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. +Both WebSocket carriers still create and delete the parent session over HTTPS. They then use a strict bodyless `GET /api/v1/ws` Upgrade request. `websocket` offers exactly `tproxy-v1.` in `Sec-WebSocket-Protocol`; binary messages are ordered carrier batches, and a protocol, deadline, or connection failure closes the complete parent session. `websocket-lanes` offers exactly `tproxy-lane-v1..`, where the stream ID is canonical decimal in `1..=16777215`. Its first binary message must begin with `OPEN`, every frame must use that stream ID, and failure after upgrade closes only that lane. There is no lane-zero WebSocket: HTTPS carries `HELLO` and `WELCOME`, while RFC 6455 Ping/Pong supplies connection liveness. + +WebSocket codec buffers and in-flight read/write messages share the process-owned `pending_bytes_global` budget with carrier queues and are additionally bounded by `websocket_bytes_global`. Admission leaves `websocket_http_connection_reserve` accepted connections for ordinary HTTP and decoys. Under pressure, replacement is owner-first, then least-recently-progressed with pre-Upgrade and dead connections ahead of live lanes and multiplexed sessions. A transport Ping is sent after `long_poll_secs` without peer activity, including during continuous downlink traffic; missing peer activity for twice that creation-time interval makes a connection eligible for cleanup. + +Every pre-Upgrade authentication, shape, lane-reservation, or capacity failure follows the sanitized decoy path instead of exposing a WebSocket-specific status. The exact subprotocol contains the session bearer and must not be logged. + 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. @@ -119,6 +125,11 @@ All WEB keys and defaults are listed in the [configuration reference](../Config_ ## NGINX TLS termination ```nginx +map $http_upgrade $telemt_connection_upgrade { + default upgrade; + '' ''; +} + upstream telemt_web { server 127.0.0.1:18080; keepalive 64; @@ -140,11 +151,12 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header Connection ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $telemt_connection_upgrade; proxy_connect_timeout 5s; - proxy_send_timeout 35s; - proxy_read_timeout 35s; + proxy_send_timeout 65s; + proxy_read_timeout 65s; proxy_request_buffering off; proxy_buffering off; proxy_next_upstream off; @@ -152,9 +164,9 @@ server { } ``` -`client_max_body_size` must be at least `web.limits.max_body_bytes`. `proxy_read_timeout` and `proxy_send_timeout` must exceed `web.timeouts.long_poll_secs`, which defaults to 25 seconds. Overwrite, rather than append to, `X-Forwarded-For`. Telemt accepts one parseable IP address; if a trusted terminator omits the header, Telemt falls back to the direct peer address, but per-client limits and source policy then see the terminator rather than the real client. Do not enable upstream retries: the bridge performs byte-identical retries through its own sequence protocol. +Place the `map` in NGINX's `http` context. `client_max_body_size` must be at least `web.limits.max_body_bytes`. Read, send, and client timeouts must exceed both the 25-second default long poll and twice the configured WebSocket liveness interval; 65 seconds covers the defaults. Overwrite, rather than append to, `X-Forwarded-For`. Telemt accepts one parseable IP address; if a trusted terminator omits the header, Telemt falls back to the direct peer address, but per-client limits and source policy then see the terminator rather than the real client. Do not enable upstream retries: the bridge performs byte-identical HTTPS retries, while an established WebSocket is never transparently replayed. -Public HTTP/2 is mandatory for `https-lanes`; use the equivalent HTTP/2 directive supported by the installed NGINX release. The private NGINX-to-Telemt hop intentionally remains HTTP/1.1. Ensure the upstream connection capacity can sustain the expected simultaneous lane polls; `keepalive` controls the idle pool and is not a concurrency limit. +Public HTTP/2 is mandatory for `https-lanes`; use the equivalent HTTP/2 directive supported by the installed NGINX release. WebSocket Upgrade requires HTTP/1.1, so the public endpoint must also permit HTTP/1.1 and the private NGINX-to-Telemt hop remains HTTP/1.1. Preserve `Connection`, `Upgrade`, and `Sec-WebSocket-*` exactly as shown. Ensure the upstream connection capacity can sustain the expected simultaneous lane polls or WebSocket lanes; `keepalive` controls the idle pool and is not a concurrency limit. ## HAProxy TLS termination @@ -171,14 +183,14 @@ backend telemt_web option http-keep-alive retries 0 timeout connect 5s - timeout server 35s + timeout server 65s 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. +The frontend or `defaults` section must also set `timeout client 65s` or longer for the default WebSocket liveness interval. HAProxy's public ALPN must include `h2` for `https-lanes` and `http/1.1` for WebSocket Upgrade. Preserve `Connection`, `Upgrade`, and `Sec-WebSocket-*`; 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 @@ -186,21 +198,22 @@ The frontend or `defaults` section must also set `timeout client` above the long | --- | --- | | 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. | +| `web.enabled`, `web.carrier`, `web.debug`, 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 session deadlines; newly issued bridge sessions use the active carrier. WebSocket write, backpressure, and eviction operations read the active hot-reloaded deadlines. 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. +API management is available, but it is intentionally partial. There is no mutable `/v1/web` resource; the API listener exposes the read-only HTML debug view at `/web-status`. | 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. | +| Inspect bounded server-side WEB request and lifecycle details | Yes, through authenticated `GET /web-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. | @@ -217,6 +230,30 @@ 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. +### Server-side WEB debug view + +Enable bounded collection in the owned configuration file: + +```toml +[web.debug] +enabled = true +capture_lifecycle = true +capture_headers = true +capture_timings = true +capture_frames = true +body_capture = "metadata" +body_prefix_bytes = 4096 +decoy_body_prefix_bytes = 4096 +default_window_secs = 180 +max_window_secs = 3600 +``` + +Open `http://127.0.0.1:9091/web-status` with the same direct-peer whitelist and exact `Authorization` header used by the API. A trailing slash is accepted. Only `GET` is allowed. The page supports `window_secs`, canonical `ip`, numeric `session`, case-insensitive `user_agent`, and `key` filters. Repeat `group_by=ip`, `group_by=session`, `group_by=user_agent`, or `group_by=key` to build grouped summaries; `limit` is restricted to `1..=1000`. HTTP rows expand from request through response with method, path, sanitized headers, body metadata or bytes, timing points, parsed frames, and typed lifecycle events. WebSocket operation adds the sanitized `GET` to `101` handshake plus bounded per-message direction, message type, payload/body capture, processing time, connection/lane identifiers, and parsed inner frames. Raw subprotocols and session tokens are never retained. + +The process-owned ring survives runtime generation replacement. Capture-policy changes clear incompatible retained records; window-only changes do not. The ring defaults to 65536 records and 64 MiB retained plus in-flight bytes, the HTML response is capped at 8 MiB, grouping is capped at 1024 groups, and no more than two response bodies retain page permits concurrently. Change `web.limits.debug_records_capacity` or `web.limits.debug_bytes_global` only with a process restart. A hot prefix that fits only a simultaneously increased restart-only capacity is deferred until that restart. + +`body_capture = "off"` omits bodies, `metadata` retains lengths and terminal states, `prefix` retains configured prefixes, and `full` retains recognized carrier bodies up to `web.limits.max_body_bytes`. Ordinary decoy bodies remain limited by `decoy_body_prefix_bytes` even in `full` mode. Queries and raw capabilities are never stored; credential header values are omitted; known WEB capabilities and bearer tokens are scrubbed from captured bodies; the displayed key is a non-secret domain-separated fingerprint. Timing ends at Hyper body polling and does not claim kernel flush or TCP acknowledgment. + 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 @@ -264,8 +301,9 @@ See the complete [Control API contract](../Architecture/API/API.md) for request 3. Confirm that Telemt receives one parseable `X-Forwarded-For` address and `Host: proxy.example.com` or `Host: proxy.example.com:443`. 4. Import the printed `tg://webproxy` link in the intended Telegram Desktop build and establish a proxy connection. 5. For `https-lanes`, confirm that the public connection negotiated HTTP/2 and exercise at least two simultaneous logical streams; the private Telemt hop remains HTTP/1.1. -6. Exercise reconnect and at least one long poll beyond 25 seconds to prove the frontend timeouts do not truncate the carrier. -7. Verify user and logical MTProxy connection limits using logical-stream counters, not the number of HTTP connections. +6. For `websocket`, confirm one `101` response, binary relay traffic, and RFC 6455 Ping/Pong beyond 25 seconds. For `websocket-lanes`, exercise at least two simultaneous stream sockets and verify that closing or corrupting one lane does not close its sibling or parent session. +7. Exercise reconnect and at least one long poll beyond 25 seconds to prove the frontend timeouts do not truncate the carrier. +8. Verify user and logical MTProxy connection limits using logical-stream counters, not the number of HTTP connections. ## Troubleshooting @@ -274,6 +312,9 @@ See the complete [Control API contract](../Architecture/API/API.md) for request | 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 parseable `X-Forwarded-For` value. | | Long polls disconnect near a fixed interval | Raise NGINX/HAProxy client, server, send, and read timeouts above `web.timeouts.long_poll_secs`. | +| WebSocket Upgrade reaches the decoy instead of returning `101` | Preserve HTTP/1.1 `Connection: Upgrade`, `Upgrade: websocket`, the single exact `Sec-WebSocket-Protocol`, and the canonical bodyless `/api/v1/ws` request. Also check carrier/session compatibility and the process connection reserve. | +| One `websocket-lanes` stream closes while siblings stay connected | This is the intended failure boundary. Inspect that lane's message/frame rows in `/web-status`; malformed, cross-lane, write-timeout, and backend-close paths terminate only the affected lane. | +| `/web-status` is empty | Confirm `[web.debug].enabled = true`, apply the configuration, select a window within `max_window_secs`, and generate new WEB traffic after the policy change. | | `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. | diff --git a/docs/WEB/WEB_PROXY.ru.md b/docs/WEB/WEB_PROXY.ru.md index 3a496e6..b993550 100644 --- a/docs/WEB/WEB_PROXY.ru.md +++ b/docs/WEB/WEB_PROXY.ru.md @@ -2,7 +2,7 @@ [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. +WEB-режим переносит обычные MTProxy-потоки через bounded HTTPS или WebSocket carriers, совместимые с типом прокси `WEB` в Telegram Desktop. Telemt не терминирует TLS: публичный сертификат обслуживает NGINX или HAProxy, который передаёт обычный HTTP/1.1 на приватный listener Telemt. > [!IMPORTANT] > @@ -12,7 +12,7 @@ WEB-режим переносит обычные MTProxy-потоки через ```text Telegram Desktop - | HTTPS :443 + | HTTPS или WSS :443 v NGINX или HAProxy (TLS termination, канонический Host и один адрес X-Forwarded-For) | обычный HTTP/1.1 в приватной сети @@ -28,7 +28,7 @@ WEB-listener 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 не анонсируются. +- `web.carrier = "https"` выбирает сериализованные HTTPS uplink и long polling. `https-lanes` выбирает независимые HTTPS sequencing и polling для каждого logical stream. `websocket` выбирает один упорядоченный WebSocket для всех streams. `websocket-lanes` выбирает отдельный WebSocket с независимым ownership для каждого ненулевого logical stream. - Capability, bootstrap и session credentials — отдельные значения с ограниченным сроком жизни. Carrier credentials считаются секретами и не должны попадать в access logs. - Bootstrap является bearer credential, а не token с привязкой к source address. Адрес клиента и его IP-семейство могут измениться между загрузкой bridge и созданием session. Адрес выдачи продолжает учитываться в лимите неиспользованных bootstrap, а владельцем session становится адрес первого корректного запроса создания. - Внутренняя MTProxy-аутентификация ограничена пользователем и режимом секрета, выбранными профилем vhost. Некорректный внутренний handshake закрывает только свой logical stream и никогда не попадает в TCP masking path. @@ -99,6 +99,12 @@ max_streams_per_session = 64 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. +Оба WebSocket carrier по-прежнему создают и удаляют parent session через HTTPS, после чего используют строгий bodyless Upgrade-запрос `GET /api/v1/ws`. `websocket` передаёт в `Sec-WebSocket-Protocol` ровно `tproxy-v1.`; binary messages являются упорядоченными carrier batches, а ошибка протокола, deadline или connection закрывает всю parent session. `websocket-lanes` передаёт ровно `tproxy-lane-v1..`, где stream ID записан каноническим десятичным числом из диапазона `1..=16777215`. Первое binary message должно начинаться с `OPEN`, все frames должны содержать этот stream ID, а сбой после Upgrade закрывает только данную lane. Lane-zero WebSocket отсутствует: HTTPS переносит `HELLO` и `WELCOME`, а liveness connection обеспечивает RFC 6455 Ping/Pong. + +WebSocket codec buffers и находящиеся в обработке read/write messages делят process-owned `pending_bytes_global` с carrier queues и дополнительно ограничены `websocket_bytes_global`. Admission оставляет `websocket_http_connection_reserve` принятых connections для обычного HTTP и decoy. При pressure вытеснение сначала выбирает того же owner, затем connection с наиболее старым прогрессом; pre-Upgrade и dead connections идут раньше активных lanes и multiplexed sessions. После `long_poll_secs` без peer activity отправляется transport Ping, в том числе при непрерывном downlink traffic, а отсутствие peer activity в течение удвоенного creation-time интервала делает connection кандидатом на cleanup. + +Любая ошибка authentication, shape, lane reservation или capacity до Upgrade следует по очищенному decoy path и не раскрывает WebSocket-специфичный status. Точный subprotocol содержит session bearer и не должен попадать в logs. + Для 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. @@ -119,6 +125,11 @@ index = "index.html" ## Терминация TLS на NGINX ```nginx +map $http_upgrade $telemt_connection_upgrade { + default upgrade; + '' ''; +} + upstream telemt_web { server 127.0.0.1:18080; keepalive 64; @@ -140,11 +151,12 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header Connection ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $telemt_connection_upgrade; proxy_connect_timeout 5s; - proxy_send_timeout 35s; - proxy_read_timeout 35s; + proxy_send_timeout 65s; + proxy_read_timeout 65s; proxy_request_buffering off; proxy_buffering off; proxy_next_upstream off; @@ -152,9 +164,9 @@ server { } ``` -`client_max_body_size` должен быть не меньше `web.limits.max_body_bytes`. Значения `proxy_read_timeout` и `proxy_send_timeout` должны превышать `web.timeouts.long_poll_secs`, по умолчанию равный 25 секундам. Перезаписывайте `X-Forwarded-For`, а не дополняйте его. Telemt принимает один корректно разбираемый IP-адрес; если доверенный TLS-терминатор не передал header, Telemt использует адрес непосредственного peer, но per-client limits и source policy тогда видят терминатор вместо реального клиента. Не включайте upstream retries: byte-identical retry выполняет сам bridge по своему sequence protocol. +Разместите `map` в контексте `http` NGINX. `client_max_body_size` должен быть не меньше `web.limits.max_body_bytes`. Read, send и client timeouts должны превышать как default long poll в 25 секунд, так и удвоенный WebSocket liveness interval; 65 секунд покрывают defaults. Перезаписывайте `X-Forwarded-For`, а не дополняйте его. Telemt принимает один корректно разбираемый IP-адрес; если доверенный TLS-терминатор не передал header, Telemt использует адрес непосредственного peer, но per-client limits и source policy тогда видят терминатор вместо реального клиента. Не включайте upstream retries: bridge выполняет byte-identical HTTPS retries, но установленный WebSocket никогда не replay’ится прозрачно. -Для `https-lanes` обязателен публичный HTTP/2; используйте эквивалентную HTTP/2-директиву, поддерживаемую установленной версией NGINX. Приватный hop NGINX-to-Telemt намеренно остаётся HTTP/1.1. Upstream connection capacity должна выдерживать ожидаемое число одновременных lane polls; `keepalive` управляет idle pool и не является лимитом concurrency. +Для `https-lanes` обязателен публичный HTTP/2; используйте эквивалентную HTTP/2-директиву, поддерживаемую установленной версией NGINX. WebSocket Upgrade требует HTTP/1.1, поэтому публичный endpoint должен также разрешать HTTP/1.1, а приватный hop NGINX-to-Telemt остаётся HTTP/1.1. Сохраняйте `Connection`, `Upgrade` и `Sec-WebSocket-*` ровно как в примере. Upstream connection capacity должна выдерживать ожидаемое число одновременных lane polls или WebSocket lanes; `keepalive` управляет idle pool и не является лимитом concurrency. ## Терминация TLS на HAProxy @@ -171,14 +183,14 @@ backend telemt_web option http-keep-alive retries 0 timeout connect 5s - timeout server 35s + timeout server 65s 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`. +Во frontend или секции `defaults` также задайте `timeout client 65s` или больше для default WebSocket liveness interval. Для `https-lanes` публичный ALPN HAProxy должен содержать `h2`, а для WebSocket Upgrade — `http/1.1`. Сохраняйте `Connection`, `Upgrade` и `Sec-WebSocket-*`; не переписывайте path, raw query, body и carrier headers `Authorization`, `Content-Type`, `X-Up-Seq`, `X-Down-Cursor`, `X-Lane-ID`. ## Lifecycle и reload @@ -186,21 +198,22 @@ backend telemt_web | --- | --- | | Состав 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.enabled`, `web.carrier`, `web.debug`, timeouts, vhosts, profiles и decoys | Применяются config watcher или runtime generation reload. | +| Существующие HTTP connections и WEB sessions | Сохраняют carrier, лимиты и session deadlines своего момента создания; новые bridge sessions получают активный carrier. WebSocket write, backpressure и eviction operations читают активные hot-reloaded deadlines. Новые 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 доступно, но намеренно ограничено. Изменяемого ресурса `/v1/web` нет; API-listener предоставляет read-only HTML debug view по адресу `/web-status`. | Операция | Поддержка 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` с последующей проверкой статуса операции. | +| Просмотр bounded серверных WEB request- и lifecycle-деталей | Да, через аутентифицированный `GET /web-status`. | | Управление `[access.users]` | Да, через `/v1/users`. Создание пользователя не создаёт WEB-профиль. | | Отзыв отдельного пользователя | Да. `/v1/users/{username}/disable` немедленно обновляет admission и завершает активные сессии пользователя. | @@ -217,6 +230,30 @@ read_only = false API whitelist проверяет непосредственный TCP peer и не доверяет `X-Forwarded-For`. Изменения самой секции `[server.api]` требуют перезапуска процесса. +### Серверная WEB-отладка + +Включите bounded сбор в конфигурационном файле, которому принадлежит эта секция: + +```toml +[web.debug] +enabled = true +capture_lifecycle = true +capture_headers = true +capture_timings = true +capture_frames = true +body_capture = "metadata" +body_prefix_bytes = 4096 +decoy_body_prefix_bytes = 4096 +default_window_secs = 180 +max_window_secs = 3600 +``` + +Откройте `http://127.0.0.1:9091/web-status`, используя те же whitelist непосредственных peers и точный header `Authorization`, что и для API. Завершающий slash разрешён. Допускается только `GET`. Страница поддерживает фильтры `window_secs`, канонический `ip`, числовой `session`, регистронезависимый `user_agent` и `key`. Повторяйте `group_by=ip`, `group_by=session`, `group_by=user_agent` или `group_by=key` для построения сгруппированных сводок; `limit` ограничен диапазоном `1..=1000`. HTTP rows раскрываются от request до response с method, path, очищенными headers, метаданными или байтами body, timing points, frames и типизированными lifecycle events. Для WebSocket добавляются очищенный handshake `GET` → `101` и bounded per-message direction, message type, payload/body capture, processing time, connection/lane identifiers и разобранные inner frames. Raw subprotocol и session tokens никогда не сохраняются. + +Process-owned кольцевой буфер переживает замену runtime generation. Изменения capture policy очищают несовместимые сохранённые записи; изменения только окна наблюдения этого не делают. По умолчанию кольцо ограничено 65536 записями и 64 MiB сохранённых плюс находящихся в обработке данных, HTML-response — 8 MiB, grouping — 1024 группами; одновременно page permits могут удерживать не более двух response bodies. Изменяйте `web.limits.debug_records_capacity` или `web.limits.debug_bytes_global` только с перезапуском процесса. Hot prefix, который помещается только в одновременно увеличенную restart-only ёмкость, откладывается до этого перезапуска. + +`body_capture = "off"` исключает bodies, `metadata` сохраняет длину и terminal state, `prefix` — настроенные prefixes, а `full` — распознанные carrier bodies до `web.limits.max_body_bytes`. Обычные decoy bodies даже в режиме `full` ограничены `decoy_body_prefix_bytes`. Queries и raw capabilities никогда не сохраняются; значения credential headers исключаются; известные WEB capabilities и bearer tokens удаляются из захваченных bodies; отображаемый ключ является несекретным domain-separated fingerprint. Timing заканчивается на polling Hyper body и не означает kernel flush или TCP acknowledgment. + После атомарного изменения TOML-файла администратором или системой управления конфигурацией задайте в `TELEMT_API_AUTH` точное значение `auth_header` и отправьте наблюдаемый generation reload: ```bash @@ -264,8 +301,9 @@ curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \ 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. +6. Для `websocket` подтвердите один response `101`, binary relay traffic и RFC 6455 Ping/Pong после 25 секунд. Для `websocket-lanes` проверьте как минимум два одновременных stream sockets и убедитесь, что закрытие или повреждение одной lane не закрывает sibling или parent session. +7. Проверьте reconnect и как минимум один long poll длительнее 25 секунд, чтобы frontend timeouts не обрывали carrier. +8. Проверяйте лимиты пользователя и logical MTProxy connections по logical-stream counters, а не по числу HTTP connections. ## Диагностика @@ -274,6 +312,9 @@ curl -sS -X POST http://127.0.0.1:9091/v1/users/web-user/rotate-secret \ | WEB-конфигурация валидна на диске, но поведение listener’а не изменилось | Проверьте `deferred_process_fields`; listener и `[web.limits]` требуют перезапуска. | | Carrier-запросы попадают в decoy | Проверьте точный vhost, secret mode ссылки, CIDR непосредственного proxy и единственное корректно разбираемое значение `X-Forwarded-For`. | | Long polls разрываются через фиксированный интервал | Поднимите client, server, send и read timeouts NGINX/HAProxy выше `web.timeouts.long_poll_secs`. | +| WebSocket Upgrade попадает в decoy вместо `101` | Сохраните HTTP/1.1 `Connection: Upgrade`, `Upgrade: websocket`, единственный точный `Sec-WebSocket-Protocol` и канонический bodyless request `/api/v1/ws`. Также проверьте соответствие carrier/session и process connection reserve. | +| Один stream `websocket-lanes` закрылся, а siblings остались подключены | Это штатная failure boundary. Проверьте message/frame rows этой lane в `/web-status`; malformed, cross-lane, write-timeout и backend-close закрывают только затронутую lane. | +| `/web-status` пуст | Убедитесь, что `[web.debug].enabled = true`, примените конфигурацию, выберите окно в пределах `max_window_secs` и создайте новый WEB-трафик после изменения policy. | | `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 локальны для процесса. | diff --git a/src/api/mod.rs b/src/api/mod.rs index 40bfdc7..7126f4f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,6 +30,7 @@ use crate::startup::StartupTracker; use crate::stats::Stats; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::MePool; +use crate::web::trace::WebTraceStore; mod config_edit; pub(crate) mod config_store; @@ -47,6 +48,7 @@ mod runtime_stats; mod runtime_watch; mod runtime_zero; mod users; +mod web_status; use config_store::{ current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk, @@ -122,6 +124,7 @@ pub(super) struct ApiShared { pub(super) proxy_shared: Arc, pub(super) reload_control: ReloadControl, pub(super) active_runtime: Arc>, + pub(super) web_trace: Arc, } impl ApiShared { @@ -155,6 +158,7 @@ impl ApiShared { proxy_shared: runtime.proxy_shared.clone(), reload_control: self.reload_control.clone(), active_runtime: self.active_runtime.clone(), + web_trace: self.web_trace.clone(), } } } @@ -243,7 +247,8 @@ fn allowed_methods_for_path(path: &str) -> Option<&'static str> { | "/v1/runtime/tls-fingerprints" | "/v1/stats/users/active-ips" | "/v1/stats/users/quota" - | "/v1/stats/users" => Some(ALLOW_GET), + | "/v1/stats/users" + | "/web-status" => Some(ALLOW_GET), "/v1/system/reload" => Some(ALLOW_POST), "/v1/users" => Some(ALLOW_GET_POST), "/v1/config" => Some(ALLOW_GET_PATCH), @@ -279,6 +284,7 @@ pub async fn serve( reload_control: ReloadControl, mut active_runtime_rx: watch::Receiver>>>, mut runtime_watch_rx: watch::Receiver>, + web_trace: Arc, ) { let active_runtime = loop { if let Some(active_runtime) = active_runtime_rx.borrow().clone() { @@ -312,7 +318,7 @@ pub async fn serve( } }; - info!("API endpoint: http://{}/v1/*", listen); + info!("API endpoint: http://{}/v1/* and /web-status", listen); let runtime_state = Arc::new(ApiRuntimeState { process_started_at_epoch_secs, @@ -344,6 +350,7 @@ pub async fn serve( proxy_shared, reload_control, active_runtime, + web_trace, }); spawn_runtime_watchers( @@ -492,6 +499,9 @@ async fn handle( let result: Result>, ApiFailure> = async { match (method.as_str(), normalized_path) { + ("GET", "/web-status") => { + Ok(web_status::render(query.as_deref(), &shared.web_trace, &cfg.web.debug).await) + } ("GET", "/v1/health") => { let revision = current_revision(&shared.config_path).await?; let data = HealthData { diff --git a/src/api/reload_tests.rs b/src/api/reload_tests.rs index 0e62141..6d35607 100644 --- a/src/api/reload_tests.rs +++ b/src/api/reload_tests.rs @@ -112,6 +112,7 @@ fn reload_routes_expose_only_documented_methods_and_ids() { Some(ALLOW_GET) ); assert_eq!(reload_status_route_id("/v1/system/reload/42"), Some(42)); + assert_eq!(allowed_methods_for_path("/web-status"), Some(ALLOW_GET)); assert_eq!( reload_status_route_id("/v1/system/reload/not-a-number"), None diff --git a/src/api/web_status.rs b/src/api/web_status.rs new file mode 100644 index 0000000..41ef777 --- /dev/null +++ b/src/api/web_status.rs @@ -0,0 +1,521 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::header::{self, HeaderValue}; +use hyper::{Response, StatusCode}; +use tokio::sync::OwnedSemaphorePermit; + +use crate::config::WebDebugConfig; +use crate::web::trace::{StoredTraceRecord, TraceRecord, TraceRecordKind, WebTraceStore}; + +const MAX_PAGE_BYTES: usize = 8 * 1024 * 1024; +const MAX_GROUPS: usize = 1024; + +// Record-detail rendering remains isolated from filtering and page layout. +mod details; +// Query parsing and matching remain independent from bounded HTML rendering. +mod query; + +use details::{push_body, push_frames, push_headers}; +use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches}; + +struct GroupSummary { + count: usize, + latest_seq: u64, +} + +struct RenderedPage { + html: String, + _permit: OwnedSemaphorePermit, +} + +impl AsRef<[u8]> for RenderedPage { + fn as_ref(&self) -> &[u8] { + self.html.as_bytes() + } +} + +/// Renders the authenticated server-side WEB debugging table. +pub(super) async fn render( + raw_query: Option<&str>, + store: &Arc, + policy: &WebDebugConfig, +) -> Response> { + store.apply_policy(policy); + let query = match parse_query(raw_query, policy) { + Ok(query) => query, + Err(error) => return html_error(StatusCode::BAD_REQUEST, "Invalid query", &error), + }; + let Some(render_permit) = store.try_render_permit() else { + return html_error( + StatusCode::SERVICE_UNAVAILABLE, + "Renderer busy", + "Two WEB status pages are already rendering", + ); + }; + let now_millis = crate::web::trace::store_epoch_millis(); + let since_millis = query + .record + .is_none() + .then(|| now_millis.saturating_sub(query.window_secs.saturating_mul(1000))) + .unwrap_or(0); + let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis)); + let status = store.status(); + let mut html = String::with_capacity(MAX_PAGE_BYTES); + push_page_start(&mut html); + html.push_str("

WEB status

"); + push_filter_form(&mut html, &query); + html.push_str("

Store

"); + summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled)); + summary_row(&mut html, "body capture", body_mode(&status.policy)); + summary_row(&mut html, "window seconds", &query.window_secs.to_string()); + summary_row( + &mut html, + "records", + &format!("{} / {}", status.records, status.records_capacity), + ); + summary_row( + &mut html, + "bytes", + &format!("{} / {}", status.used_bytes, status.bytes_capacity), + ); + summary_row(&mut html, "matched", &records.len().to_string()); + summary_row( + &mut html, + "contention drops", + &status.contention_drops.to_string(), + ); + summary_row(&mut html, "evictions", &status.evictions.to_string()); + summary_row( + &mut html, + "byte truncations", + &status.byte_truncations.to_string(), + ); + summary_row( + &mut html, + "sequence range", + &format!( + "{} .. {}", + option_u64(status.earliest_seq), + option_u64(status.latest_seq) + ), + ); + html.push_str("
"); + if !query.group_by.is_empty() { + push_groups(&mut html, &records, &query.group_by); + } + push_records(&mut html, &records, &query); + html.push_str(""); + truncate_page(&mut html); + retained_html_response(StatusCode::OK, html, render_permit) +} + +fn push_page_start(html: &mut String) { + html.push_str("WEB status
"); +} + +fn push_filter_form(html: &mut String, query: &StatusQuery) { + html.push_str("

Filters

"); + input(html, "window_secs", &query.window_secs.to_string()); + input( + html, + "ip", + &query.ip.map(|value| value.to_string()).unwrap_or_default(), + ); + input( + html, + "session", + &query + .session + .map(|value| value.to_string()) + .unwrap_or_default(), + ); + input( + html, + "user_agent", + query.user_agent.as_deref().unwrap_or_default(), + ); + input(html, "key", query.key.as_deref().unwrap_or_default()); + input(html, "limit", &query.limit.to_string()); + html.push_str("
"); +} + +fn input(html: &mut String, name: &str, value: &str) { + html.push_str(""); +} + +fn summary_row(html: &mut String, name: &str, value: &str) { + html.push_str(""); + escape(html, name); + html.push_str(""); + escape(html, value); + html.push_str(""); +} + +fn push_groups(html: &mut String, records: &[Arc], groups: &[GroupBy]) { + let mut summaries = BTreeMap::, GroupSummary>::new(); + let mut overflow = 0usize; + for stored in records { + let values = groups + .iter() + .map(|group| group_value(&stored.record, *group)) + .collect::>(); + if let Some(summary) = summaries.get_mut(&values) { + summary.count += 1; + summary.latest_seq = summary.latest_seq.max(stored.record.seq); + } else if summaries.len() < MAX_GROUPS { + summaries.insert( + values, + GroupSummary { + count: 1, + latest_seq: stored.record.seq, + }, + ); + } else { + overflow += 1; + } + } + let mut summaries = summaries.into_iter().collect::>(); + summaries.sort_by(|(left_values, left), (right_values, right)| { + right + .count + .cmp(&left.count) + .then_with(|| left_values.cmp(right_values)) + }); + html.push_str("

Groups

"); + for group in groups { + html.push_str(""); + } + html.push_str(""); + for (values, summary) in summaries { + html.push_str(""); + for value in values { + html.push_str(""); + } + html.push_str(""); + if html.len() >= MAX_PAGE_BYTES / 2 { + break; + } + } + if overflow != 0 { + html.push_str(""); + } + html.push_str("
"); + html.push_str(group.as_str()); + html.push_str("recordslatest seq
"); + escape(html, &value); + html.push_str(""); + html.push_str(&summary.count.to_string()); + html.push_str(""); + html.push_str(&summary.latest_seq.to_string()); + html.push_str("
Additional groups omitted: "); + html.push_str(&overflow.to_string()); + html.push_str("
"); +} + +fn group_value(record: &TraceRecord, group: GroupBy) -> String { + match group { + GroupBy::Ip => client_ip(record).map(|value| value.to_string()), + GroupBy::Session => record.identity.session_id.map(|value| value.to_string()), + GroupBy::UserAgent => record.user_agent.clone(), + GroupBy::Key => record.identity.key_fingerprint.clone(), + } + .unwrap_or_else(|| "-".to_string()) +} + +fn push_records(html: &mut String, records: &[Arc], query: &StatusQuery) { + html.push_str("

Records

"); + let mut shown = 0usize; + for stored in records.iter().take(query.limit) { + if html.len() >= MAX_PAGE_BYTES.saturating_sub(64 * 1024) { + break; + } + push_record(html, &stored.record); + shown += 1; + } + if shown == 0 { + html.push_str(""); + } + html.push_str("
seqtimekindroute/eventmethodstatusIPsessionuser / keyUser-Agentdetails
No matching records
"); + if records.len() > shown && shown != 0 { + let before = records[shown - 1].record.seq; + html.push_str("

Next page

"); + } + html.push_str("
"); +} + +fn push_record(html: &mut String, record: &TraceRecord) { + html.push_str(""); + html.push_str(&record.seq.to_string()); + html.push_str(""); + escape(html, &format_time(record.epoch_millis)); + let (kind, route, method, status) = match &record.kind { + TraceRecordKind::Http(http) => ( + "http", + http.route.as_str(), + http.method.as_str(), + http.status + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + ), + TraceRecordKind::Websocket(message) => ( + "websocket", + message.direction.as_str(), + message.message_type, + message.payload_bytes.to_string(), + ), + TraceRecordKind::Lifecycle(event) => ( + "lifecycle", + event.event.as_str(), + "-", + event.reason.unwrap_or("-").to_string(), + ), + }; + for value in [kind, route, method, status.as_str()] { + html.push_str(""); + escape(html, value); + } + html.push_str(""); + escape( + html, + &client_ip(record) + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + ); + html.push_str(""); + escape(html, &option_u64(record.identity.session_id)); + html.push_str(""); + escape(html, record.identity.user.as_deref().unwrap_or("-")); + html.push_str(" / "); + escape( + html, + record.identity.key_fingerprint.as_deref().unwrap_or("-"), + ); + html.push_str(""); + escape(html, record.user_agent.as_deref().unwrap_or("-")); + html.push_str("
request → response"); + match &record.kind { + TraceRecordKind::Http(http) => { + html.push_str("

"); + escape(html, &http.method); + html.push(' '); + escape(html, &http.path); + html.push_str("

"); + push_headers(html, "request headers", &http.request_headers); + push_body(html, "request body", http.request_body.as_ref()); + push_headers(html, "response headers", &http.response_headers); + push_body(html, "response body", http.response_body.as_ref()); + if let Some(timings) = &http.timings { + html.push_str("

timings

service/head accepted: 0 us\nrequest body: ");
+                html.push_str(&option_u64(timings.request_body_us));
+                html.push_str(" us\nresponse ready: ");
+                html.push_str(&option_u64(timings.response_ready_us));
+                html.push_str(" us\nresponse body consumed/polled: ");
+                html.push_str(&option_u64(timings.response_body_us));
+                html.push_str(" us\n(kernel flush and TCP ACK are not observed)
"); + } + push_frames(html, &http.frames); + } + TraceRecordKind::Websocket(message) => { + html.push_str("
connection: ");
+            html.push_str(&message.connection_id.to_string());
+            html.push_str("\nlane: ");
+            html.push_str(
+                &message
+                    .lane_id
+                    .map(|value| value.to_string())
+                    .unwrap_or_else(|| "-".to_string()),
+            );
+            html.push_str("\ndirection: ");
+            html.push_str(message.direction.as_str());
+            html.push_str("\nmessage: ");
+            html.push_str(message.message_type);
+            html.push_str("\npayload bytes: ");
+            html.push_str(&message.payload_bytes.to_string());
+            html.push_str("\nduration: ");
+            html.push_str(&option_u64(message.duration_us));
+            html.push_str(" us
"); + push_body(html, "message body", message.body.as_ref()); + push_frames(html, &message.frames); + } + TraceRecordKind::Lifecycle(event) => { + html.push_str("
event: ");
+            html.push_str(event.event.as_str());
+            html.push_str("\nstream: ");
+            html.push_str(
+                &event
+                    .stream_id
+                    .map(|v| v.to_string())
+                    .unwrap_or_else(|| "-".to_string()),
+            );
+            html.push_str("\nreason: ");
+            html.push_str(event.reason.unwrap_or("-"));
+            html.push_str("
"); + } + } + html.push_str("
"); +} + +fn pagination_url(query: &StatusQuery, before_seq: u64) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::from("/web-status?")); + serializer.append_pair("window_secs", &query.window_secs.to_string()); + if let Some(ip) = query.ip { + serializer.append_pair("ip", &ip.to_string()); + } + if let Some(session) = query.session { + serializer.append_pair("session", &session.to_string()); + } + if let Some(user_agent) = &query.user_agent { + serializer.append_pair("user_agent", user_agent); + } + if let Some(key) = &query.key { + serializer.append_pair("key", key); + } + for group in &query.group_by { + serializer.append_pair("group_by", group.as_str()); + } + serializer.append_pair("limit", &query.limit.to_string()); + serializer.append_pair("before_seq", &before_seq.to_string()); + serializer.finish() +} + +fn format_time(epoch_millis: u64) -> String { + chrono::DateTime::from_timestamp_millis(epoch_millis as i64) + .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) + .unwrap_or_else(|| epoch_millis.to_string()) +} + +fn option_u64(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn body_mode(policy: &WebDebugConfig) -> &'static str { + match policy.body_capture { + crate::config::WebDebugBodyCapture::Off => "off", + crate::config::WebDebugBodyCapture::Metadata => "metadata", + crate::config::WebDebugBodyCapture::Prefix => "prefix", + crate::config::WebDebugBodyCapture::Full => "full", + } +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn escape(output: &mut String, value: &str) { + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ => output.push(character), + } + } +} + +fn truncate_page(html: &mut String) { + const SUFFIX: &str = "[page output truncated]"; + if html.len() <= MAX_PAGE_BYTES { + return; + } + let mut end = MAX_PAGE_BYTES.saturating_sub(SUFFIX.len()); + while !html.is_char_boundary(end) { + end -= 1; + } + html.truncate(end); + html.push_str(SUFFIX); +} + +fn html_error(status: StatusCode, title: &str, message: &str) -> Response> { + let mut html = String::new(); + push_page_start(&mut html); + html.push_str("

"); + escape(&mut html, title); + html.push_str("

"); + escape(&mut html, message); + html.push_str("

"); + html_response(status, html) +} + +fn html_response(status: StatusCode, html: String) -> Response> { + html_bytes_response(status, Bytes::from(html)) +} + +fn retained_html_response( + status: StatusCode, + html: String, + permit: OwnedSemaphorePermit, +) -> Response> { + html_bytes_response( + status, + Bytes::from_owner(RenderedPage { + html, + _permit: permit, + }), + ) +} + +fn html_bytes_response(status: StatusCode, html: Bytes) -> Response> { + let mut response = Response::new(Full::new(html)); + *response.status_mut() = status; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"), + ); + 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(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + response +} + +#[cfg(test)] +#[path = "web_status/tests.rs"] +mod tests; diff --git a/src/api/web_status/details.rs b/src/api/web_status/details.rs new file mode 100644 index 0000000..fd21217 --- /dev/null +++ b/src/api/web_status/details.rs @@ -0,0 +1,86 @@ +use base64::Engine as _; + +use super::{MAX_PAGE_BYTES, escape, yes_no}; + +pub(super) fn push_frames(html: &mut String, frames: &[crate::web::trace::TraceFrame]) { + if frames.is_empty() { + return; + } + html.push_str("

frames

"); + for frame in frames { + html.push_str(""); + for value in [ + frame.direction.as_str().to_string(), + frame.frame_type.unwrap_or("-").to_string(), + frame + .stream_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + frame + .payload_len + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + frame + .window_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + frame.parse_error.unwrap_or("-").to_string(), + ] { + html.push_str(""); + } + html.push_str(""); + } + html.push_str("
dirtypestream/lanepayloadWINDOWerror
"); + escape(html, &value); + html.push_str("
"); +} + +pub(super) fn push_headers( + html: &mut String, + title: &str, + headers: &[crate::web::trace::TraceHeader], +) { + html.push_str("

"); + escape(html, title); + html.push_str("

");
+    for header in headers {
+        escape(html, &header.name);
+        html.push_str(": ");
+        escape(html, header.value.as_deref().unwrap_or("[value omitted]"));
+        html.push('\n');
+    }
+    html.push_str("
"); +} + +pub(super) fn push_body( + html: &mut String, + title: &str, + body: Option<&crate::web::trace::TraceBodySnapshot>, +) { + html.push_str("

"); + escape(html, title); + html.push_str("

"); + let Some(body) = body else { + html.push_str("

capture off

"); + return; + }; + html.push_str("

observed="); + html.push_str(&body.observed_bytes.to_string()); + html.push_str(" captured="); + html.push_str(&body.captured.len().to_string()); + html.push_str(" state="); + html.push_str(body.state.as_str()); + html.push_str(" truncated="); + html.push_str(yes_no(body.truncated)); + html.push_str("

");
+    let available = MAX_PAGE_BYTES
+        .saturating_sub(html.len())
+        .saturating_sub(4096);
+    let raw_limit = available.saturating_mul(3) / 4;
+    let shown = body.captured.len().min(raw_limit);
+    base64::engine::general_purpose::STANDARD.encode_string(&body.captured[..shown], html);
+    if shown < body.captured.len() {
+        html.push_str("\n[page output truncated]");
+    }
+    html.push_str("
"); +} diff --git a/src/api/web_status/query.rs b/src/api/web_status/query.rs new file mode 100644 index 0000000..cd09d8f --- /dev/null +++ b/src/api/web_status/query.rs @@ -0,0 +1,176 @@ +use std::collections::BTreeSet; +use std::net::IpAddr; + +use crate::config::WebDebugConfig; +use crate::web::trace::TraceRecord; + +const DEFAULT_LIMIT: usize = 200; +const MAX_LIMIT: usize = 1000; + +/// Supported status-page grouping dimensions. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum GroupBy { + Ip, + Session, + UserAgent, + Key, +} + +impl GroupBy { + fn parse(value: &str) -> Option { + match value { + "ip" => Some(Self::Ip), + "session" => Some(Self::Session), + "user_agent" => Some(Self::UserAgent), + "key" => Some(Self::Key), + _ => None, + } + } + + /// Returns the canonical query and table label. + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Ip => "ip", + Self::Session => "session", + Self::UserAgent => "user_agent", + Self::Key => "key", + } + } +} + +/// Validated bounded status-page filter and pagination state. +pub(super) struct StatusQuery { + pub(super) window_secs: u64, + pub(super) ip: Option, + pub(super) session: Option, + pub(super) user_agent: Option, + pub(super) key: Option, + pub(super) group_by: Vec, + pub(super) limit: usize, + pub(super) before_seq: Option, + pub(super) record: Option, +} + +/// Parses a strict query without accepting unknown or ambiguous fields. +pub(super) fn parse_query( + raw: Option<&str>, + policy: &WebDebugConfig, +) -> Result { + let mut query = StatusQuery { + window_secs: policy.default_window_secs, + ip: None, + session: None, + user_agent: None, + key: None, + group_by: Vec::new(), + limit: DEFAULT_LIMIT, + before_seq: None, + record: None, + }; + let mut seen = BTreeSet::new(); + for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + let name = name.as_ref(); + let value = value.as_ref(); + if name != "group_by" && !seen.insert(name.to_string()) { + return Err(format!("{name} must not repeat")); + } + match name { + "window_secs" => { + query.window_secs = parse_positive_u64(value, "window_secs")?; + } + "ip" => { + let parsed = value + .parse::() + .map_err(|_| "ip must be a canonical IP address".to_string())?; + if parsed.to_string() != value { + return Err("ip must use canonical formatting".to_string()); + } + query.ip = Some(parsed); + } + "session" => query.session = Some(parse_positive_u64(value, "session")?), + "user_agent" => { + if value.is_empty() || value.len() > 512 { + return Err("user_agent must contain 1..512 bytes".to_string()); + } + query.user_agent = Some(value.to_string()); + } + "key" => { + if value.is_empty() || value.len() > 64 { + return Err("key must contain 1..64 bytes".to_string()); + } + query.key = Some(value.to_string()); + } + "group_by" => { + let group = GroupBy::parse(value).ok_or_else(|| { + "group_by must be ip, session, user_agent, or key".to_string() + })?; + if query.group_by.contains(&group) { + return Err("group_by values must not repeat".to_string()); + } + query.group_by.push(group); + } + "limit" => { + query.limit = value + .parse::() + .ok() + .filter(|value| (1..=MAX_LIMIT).contains(value)) + .ok_or_else(|| "limit must be within 1..1000".to_string())?; + } + "before_seq" => { + query.before_seq = Some(parse_positive_u64(value, "before_seq")?); + } + "record" => query.record = Some(parse_positive_u64(value, "record")?), + _ => return Err(format!("unknown query field `{name}`")), + } + } + if query.window_secs > policy.max_window_secs { + return Err(format!( + "window_secs must not exceed {}", + policy.max_window_secs + )); + } + Ok(query) +} + +fn parse_positive_u64(value: &str, field: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value != 0) + .ok_or_else(|| format!("{field} must be a positive integer")) +} + +/// Applies the complete filter predicate to one immutable record. +pub(super) fn record_matches(record: &TraceRecord, query: &StatusQuery, since_millis: u64) -> bool { + !(record.epoch_millis < since_millis + || query.before_seq.is_some_and(|before| record.seq >= before) + || query.record.is_some_and(|seq| record.seq != seq) + || query.ip.is_some_and(|ip| client_ip(record) != Some(ip)) + || query + .session + .is_some_and(|session| record.identity.session_id != Some(session)) + || query.user_agent.as_ref().is_some_and(|needle| { + record + .user_agent + .as_deref() + .is_none_or(|value| !contains_ascii_case_insensitive(value, needle)) + }) + || query.key.as_ref().is_some_and(|key| { + record.identity.user.as_deref() != Some(key) + && record.identity.key_fingerprint.as_deref() != Some(key) + })) +} + +fn contains_ascii_case_insensitive(value: &str, needle: &str) -> bool { + let needle = needle.as_bytes(); + needle.is_empty() + || value + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +/// Returns the trusted effective address or direct peer fallback. +pub(super) fn client_ip(record: &TraceRecord) -> Option { + record.effective_ip.or(record.peer_ip) +} diff --git a/src/api/web_status/tests.rs b/src/api/web_status/tests.rs new file mode 100644 index 0000000..f9dc268 --- /dev/null +++ b/src/api/web_status/tests.rs @@ -0,0 +1,86 @@ +use http_body_util::BodyExt as _; + +use super::*; +use crate::web::trace::{TraceIdentity, TraceLifecycleEvent}; + +#[test] +fn query_rejects_noncanonical_ip_and_excessive_window() { + let policy = WebDebugConfig::default(); + assert!(parse_query(Some("ip=2001%3A0db8%3A%3A1"), &policy).is_err()); + assert!(parse_query(Some("window_secs=3601"), &policy).is_err()); + assert!(parse_query(Some("session=1&session=2"), &policy).is_err()); +} + +#[test] +fn html_escaping_covers_active_markup_characters() { + let mut output = String::new(); + escape(&mut output, "