# tproxy-server `tproxy-server` is the hosted half of a proof-of-concept WEB proxy type for Telegram. A Telegram app keeps its normal MTProxy framing and encryption, but sends all of its proxy TCP connections through one app-owned WebView transport. The WebView carries a multiplexed session over a server-selected same-origin HTTPS or WebSocket carrier. The relay separates the logical streams again and connects each one to a stock official MTProxy on the server. The design is not tied to one Telegram client or operating system. Any Telegram app that can host a WebView and connect its MTProxy sockets to a small local adapter can implement the client side. The current proof-of-concept work includes a Telegram Desktop implementation, an experimental Android client described in [`ANDROID.md`](ANDROID.md), and an iOS client plan in [`IOS.md`](IOS.md). All use the same bridge page, carrier selection, shared frame format, and server deployment. The configured hostname remains a regular HTTPS website. A capability derived from the hostname and MTProxy secret selects a one-shot bridge page; every other normal request receives the public site. ## How it works ```text Telegram app MTProto connections with the normal MTProxy transform | v local WEB proxy adapter one logical stream per app connection | v one WebView transport and authenticated relay session multiplexed frames in the selected HTTPS/WebSocket carrier | v tproxy-server -> one local TCP connection per stream -> official MTProxy ``` The app configures only a hostname and an MTProxy secret. It derives the bridge capability locally and never exposes the raw secret to JavaScript. The WebView opens the bridge, exchanges a short-lived bootstrap token for a relay session, and runs the carrier mode selected by the matching server profile. `OPEN`, `DATA`, `WINDOW`, and `CLOSE` frames multiplex every app connection through that session. The relay treats DATA as opaque bytes: it cannot choose a Telegram destination or decrypt the MTProxy stream. “One WebView transport” means one logical carrier and relay session for the app, not one HTTP request or backend connection. The profile may use the original serialized HTTPS carrier, independent HTTPS request lanes per Telegram logical session, one multiplexed WebSocket, or an independent WebSocket per logical session. See [`PROTOCOL.md`](PROTOCOL.md) for the normative wire contract and [`PLAN.md`](PLAN.md) for the architecture, limits, implementation rationale, and remaining proof-of-concept work. [`PUBLIC_SITE.md`](PUBLIC_SITE.md) defines the operator-owned website extension points. The reference deployment layout is: ```text Internet :80/:443 -> Caddy -> 127.0.0.1:8080 tproxy-server -> operator site | (memory or loopback app) \-> 127.0.0.1:2398 official MTProxy ``` Only Caddy listens on external interfaces. The relay, its admin endpoints, the official MTProxy client port, and MTProxy statistics remain local. The relay never receives a client-selected backend address and never decrypts the MTProxy stream. Caddy proxies **every** path to the relay. In static mode the relay serves the whole site from memory through one code path and one header set. In application mode it delegates ordinary and unauthenticated requests to one private loopback web application. A request that proves knowledge of a bridge or session token is intercepted before that application. In either mode there is no separately hosted relay path for an unauthenticated prober to compare with the public site, and only `GET /?bridge=` reveals the bridge. Restart the relay after changing files under `public_dir`; the static site is read once at start-up. ## What you need - a dedicated lowercase hostname such as `proxy.example.com` that you control; - an **x86_64** Linux server with a public IPv4 address, SSH access, systemd, and either Ubuntu 22.04+ or Debian 12+; - root or passwordless `sudo` on that server; - public inbound TCP 80 and 443; - one random 16-byte secret; and - an operator-owned static site or a web application bound to a private loopback port. The automated installer is intended for a clean server on which Caddy may own ports 80/443. It backs up an existing `/etc/caddy/Caddyfile`, but it then replaces the active Caddy configuration. If the server already hosts other sites, use the manual integration section instead. ## 1. Choose the hostname and secret At your DNS provider, add an `A` record: ```text proxy.example.com -> YOUR_SERVER_PUBLIC_IPV4 ``` Add an `AAAA` record only when the server really has working public IPv6. Do not put a CDN or HTTP proxy in front of this first deployment. Publish the hostname to users in its ACE (`xn--…`) form if it is an internationalised name: the desktop client stores and derives the capability from the A-label, ACE input round-trips unchanged on every platform, while a hand-typed Unicode host containing `ß`, `ς`, ZWJ/ZWNJ, or characters newer than Unicode 3.2 can be mapped differently by the Qt 5.15 (Windows) and Qt 6 (macOS/Linux) desktop builds and then derive a different capability. Wait until the record resolves from outside your network: ```bash dig +short A proxy.example.com dig +short AAAA proxy.example.com ``` Generate the client-facing secret on your own computer: ```bash openssl rand -hex 16 ``` That produces 32 lowercase hexadecimal characters. Keep the exact value: it is entered in every client that uses this server and passed to the installer. ## 2. Prepare the real public site The repository deliberately does not include a deployable public website. If many operators installed the same starter, its body and assets would become an easy active-probing signature. The simplest choice is a static site that genuinely belongs to the operator, with generated files in a directory such as `../my-site`. The only required file is `index.html`. A several-page site will normally also have `about.html`, `privacy.html`, `404.html`, `styles.css`, a favicon, and images. The relay supports clean links such as `/about` for `about.html`; no framework or build step is required. See [`PUBLIC_SITE.md`](PUBLIC_SITE.md) for the complete package contract and a prompt suitable for a site generator. For a database-backed site, accounts, forms, server rendering, an existing CMS, site APIs, SSE, or WebSockets, run any HTTP application on a numeric loopback address such as `127.0.0.1:3000`. The relay can use it as `public_upstream` while remaining the only public gateway. The application owns its framework, headers, cookies, and persistence; four exact transport paths remain reserved. This mode is specified in [`PUBLIC_SITE.md`](PUBLIC_SITE.md). ## 3. Configure the hosting firewall In the hosting provider's network rules or firewall, allow: | Port | Source | Purpose | |---:|---|---| | TCP 22 | your administrator IP if possible | SSH | | TCP 80 | anywhere | ACME validation and HTTPS redirect | | TCP 443 | anywhere | website and WebView transport | Do not allow TCP 2398, 8080, 8081, or 8888. The installer adds a local nftables rule that drops external traffic to 2398 and 8888, but the provider firewall is the second required boundary. If the host itself runs UFW or another firewall, allow 80/443 there as well. Preserve your working SSH rule before changing anything remotely: ```bash sudo ufw status sudo ufw allow 80/tcp sudo ufw allow 443/tcp ``` ## 4. Upload and install over SSH From the parent directory on your computer, upload this working tree. `rsync` is convenient even before the repository has a remote: ```bash rsync -az --delete --exclude .git \ tproxy-server/ YOUR_SSH_USER@YOUR_SERVER_PUBLIC_IP:/tmp/tproxy-server/ ``` Then connect and run the installer, substituting the hostname and contact email: ```bash ssh YOUR_SSH_USER@YOUR_SERVER_PUBLIC_IP cd /tmp/tproxy-server sudo ./deploy/install.sh \ --hostname proxy.example.com \ --email you@example.com \ --site-dir ../my-site ``` For a local web application that is already running, use: ```bash sudo ./deploy/install.sh \ --hostname proxy.example.com \ --email you@example.com \ --site-upstream http://127.0.0.1:3000 ``` The backend defaults to one official MTProxy worker and 4096 accepted client connections per worker. On a measured multi-core deployment, the installer also accepts `--mtproxy-workers N` and `--mtproxy-max-connections N`. Keep one worker for the first deployment; raise it only while watching both MTProxy stats and relay CPU, because Caddy and the Go relay need CPU on the same host. The installer prompts without echo for the secret. Paste the exact value there. This keeps it out of the shell history and process list. For unattended provisioning, `--secret` is available, but it places the value in the invoking process list and should be used only in automation where process arguments are controlled. The installer: 1. installs nftables and build prerequisites, a checksum-verified official Caddy binary with a dedicated systemd service, and a verified Go toolchain when the host does not already have Go 1.20 or newer; 2. verifies the archive for pinned MTProxy commit `f36d8af769ffaeac36978d38c2c0f6d1104c2137`, builds it unchanged as the unprivileged `mtproxy` user, and installs the result as root; 3. downloads the official MTProxy secret and routing configuration over HTTPS; 4. runs all Go tests and installs `/usr/local/bin/tproxy-server`; 5. installs the operator-supplied static site without overwriting an existing `/srv/tproxy-site`, or configures the private site application; 6. creates mode-restricted configuration and a systemd credential for the WEB secret; 7. installs the backend firewall, relay, MTProxy, refresh timer, and Caddy units; and 8. asks Caddy to obtain and renew the hostname's public certificate. The script accepts lowercase hexadecimal secrets. The server configuration itself also accepts canonical base64url secrets if you later manage profiles manually. ## 5. Verify the deployment On the server: ```bash systemctl --no-pager --full status \ caddy tproxy-firewall mtproxy tproxy-server curl --fail http://127.0.0.1:8081/healthz curl --fail http://127.0.0.1:8081/readyz curl --silent http://127.0.0.1:8081/metrics ss -lntp sudo nft list table inet tproxy_backend ``` Expected listeners are public Caddy on 80/443 and loopback relay listeners on 8080/8081. Official MTProxy listens on 2398 because its upstream command has no bind address option; nftables must drop that port on every non-loopback interface. From your own computer, verify the site and certificate: ```bash curl --fail --show-error --location https://proxy.example.com/ curl --fail --show-error https://proxy.example.com/about openssl s_client -connect proxy.example.com:443 \ -servername proxy.example.com /dev/null \ | openssl x509 -noout -subject -issuer -dates ``` Also confirm the local-only ports are unreachable externally. These commands should time out or fail: ```bash nc -vz -w 3 YOUR_SERVER_PUBLIC_IP 2398 nc -vz -w 3 YOUR_SERVER_PUBLIC_IP 8888 nc -vz -w 3 YOUR_SERVER_PUBLIC_IP 8080 nc -vz -w 3 YOUR_SERVER_PUBLIC_IP 8081 ``` Check that missing, wrong, duplicated, or augmented bridge queries all display the same normal home page: ```bash curl --fail 'https://proxy.example.com/?bridge=wrong' curl --fail 'https://proxy.example.com/?bridge=wrong&x=1' ``` Do not paste the real derived bridge URL into logs or test commands. A conforming client derives it in memory. ## 6. Configure a Telegram client A WEB-capable Telegram app accepts exactly two user-visible values: ```text Hostname: proxy.example.com Secret: 000102030405060708090a0b0c0d0e0f ``` The hostname field contains no `https://`, port, slash, query, or fragment. HTTPS and port 443 are fixed by the WEB proxy type. Internationalized domains are stored as lowercase ASCII IDNA A-labels. The secret is the same client-facing MTProxy secret configured in the corresponding server profile. A shareable WEB proxy link is: ```text https://t.me/webproxy?server=proxy.example.com&secret=000102030405060708090a0b0c0d0e0f ``` Clients may also accept the equivalent `tg://webproxy` form. The public `t.me` frontend does not yet register this route, so proof-of-concept testing may require opening the link directly in the intended client. Current client status: - Telegram Desktop has a process-wide hidden native WebView carrier and an explicit system-browser fallback. Its full hosted test matrix is in `../tproxy/docs/web-proxy-test-plan.md`. - The Android proof of concept uses a private, foreground-scoped Android System WebView. See [`ANDROID.md`](ANDROID.md) for its build and test instructions. - The planned iOS proof of concept uses a process-wide `WKWebView` carrier. See [`IOS.md`](IOS.md) for its design and lifecycle plan. These are client implementations of the same WEB proxy protocol, not separate server modes. ### Repeating this on several hosting accounts Give every server its own hostname, for example `north.example.com` and `south.example.com`, and repeat the DNS, firewall, upload, and install steps on each host. One relay process intentionally serves one public hostname. Independent secrets make rotation and deployment management clearer; reusing a base secret is technically possible because the bridge capability also includes the hostname, but it couples all of those deployments to one credential. ## Manual integration on an existing Caddy server Build and install the relay without running `deploy/install.sh`: ```bash go test ./... go build -trimpath -o tproxy-server ./cmd/tproxy-server sudo install -m 0755 tproxy-server /usr/local/bin/tproxy-server ``` Install an operator-owned static site in `/srv/tproxy-site` or configure a local web application as described in [`PUBLIC_SITE.md`](PUBLIC_SITE.md), then create `/etc/tproxy-server/config.json` and a mode-`0400` profiles file from `config.example.json` and `profiles.example.json`. When not using systemd `LoadCredential`, point `profiles_file` directly at the mode-restricted file. Both relay listeners, the public application, and every backend address must use numeric loopback addresses. Build the official backend with `deploy/install-mtproxy.sh`, install the supplied systemd units, and let the relay serve the whole hostname: ```caddyfile encode zstd gzip reverse_proxy 127.0.0.1:8080 { transport http { response_header_timeout 40s } } ``` Do not route either static files or a site application around the relay on this hostname. The relay must remain the one gateway so that encoding, response headers, method handling, and reverse-proxy-hop timing do not expose a separate transport surface. Set the server `timeouts` as in `deploy/Caddyfile` (`read_body` well above `long_poll`). The relay must receive the original `Host`. The supplied direct-to-origin Caddy layout relies on Caddy's default sanitizing of forwarded client addresses; if you change trusted-proxy handling, the relay must still receive exactly one IP address in `X-Forwarded-For` for capacity accounting, and rejects a request that carries a list or an unparsable value. Bootstrap tokens are not bound to that address because a browser may use different VPN, carrier, or dual-stack egress connections to load the bridge and create its session. Do not apply your public site's `X-Frame-Options`, COOP, COEP, or framing CSP to the proxied root response; the bridge supplies a distinct CSP permitting only the numeric loopback parent. Do not enable access logging of raw URIs, authorization headers, or bodies. The generated bridge is self-contained and compatible with hardened native WebViews: it requires only its nonce-bearing inline script, exact-origin HTTPS Fetch and optionally same-origin WSS, timers, typed arrays, and the authenticated app boundary. It does not use cookies or browser storage, external resources, workers, frames, media, popups, downloads, forms, device permissions, clipboard APIs, or cross-origin requests. Keep the bridge response headers produced by the Go relay intact; `PROTOCOL.md` lists the complete execution policy and explains which restrictions clients must also enforce independently. ## Multiple secrets on one hostname Add profiles to `/etc/tproxy-server/profiles.json`. Every profile has a unique name, client secret, and numeric loopback backend: ```json { "profiles": [ { "name": "alpha", "secret": "0123456789abcdef0123456789abcdef", "backend": "127.0.0.1:2398", "carrier_mode": "https" }, { "name": "beta", "secret": "fedcba9876543210fedcba9876543210", "backend": "127.0.0.1:2399", "carrier_mode": "https-lanes", "limits": { "max_sessions": 32, "max_streams": 512, "max_backend_dials_in_flight": 64, "new_sessions_per_minute": 120, "new_sessions_burst": 32, "new_streams_per_minute": 1200, "new_streams_burst": 128, "max_streams_per_session": 32, "max_pending_per_session": 8388608 } } ] } ``` `carrier_mode` is optional and defaults to `https` for compatibility: | Mode | Carrier behavior | Primary tradeoff | |---|---|---| | `https` | one serialized POST plus one long poll | conservative baseline; one direction is capped near `carrier_batch_bytes / RTT` | | `https-lanes` | independent POST sequence and long poll for every logical stream | mirrors Telegram TCP sessions and isolates latency; relies on HTTP/2 for many concurrent polls | | `websocket` | one ordered WebSocket multiplexing all streams | removes HTTP stop-and-wait with one connection; all streams share its TCP congestion and failure domain | | `websocket-lanes` | one ordered WebSocket for every logical stream | isolates browser and relay queues so bulk media does not block interactive streams; increases connection and handshake count | The mode is selected through the secret/profile, so existing Desktop, Android, and iOS proof-of-concept clients need no new setting or client-side transport code. Use different secrets when exposing several modes on one hostname. Run one official MTProxy process and listener per profile when profiles need separate quotas or routing. Extend `firewall.nft` to include every added backend port. A single MTProxy may receive repeated `-S` arguments only when all profiles intentionally share the same policy and routing scope. ### Capacity limits The process-wide limits in `config.json` are the mandatory safety boundary. The important admission controls are: | Field | Default | Meaning | |---|---:|---| | `max_sessions_global` | 128 | live WebView carrier sessions | | `max_streams_global` | 4096 | live backend TCP streams | | `max_backend_dials_in_flight` | 256 | simultaneous backend connection attempts | | `new_sessions_per_minute` / `new_sessions_burst` | 600 / 128 | process-wide session creation bucket | | `new_streams_per_minute` / `new_streams_burst` | 6000 / 512 | process-wide stream creation bucket | | `max_bootstraps_global` | 512 | live one-shot bridge bootstrap entries | | `new_bootstraps_per_minute` / `new_bootstraps_burst` | 1200 / 256 | process-wide bootstrap creation bucket | | `max_pending_global` / `max_pending_items_global` | 512 MiB / 262144 | process-wide buffered relay data and allocations | `max_sessions_per_ip` and `max_bootstraps_per_ip` default to `0`, which disables those optional hard limits. This is deliberate: many legitimate users may share a carrier-grade NAT address. Set a positive value only when the deployment needs a secondary source-address abuse boundary; it does not replace the global limits. The relay refuses to start when the per-session control reserve, multiplied by `max_sessions_global`, would leave `max_pending_global` or `max_pending_items_global` with no room for data. `carrier_batch_bytes` may not exceed 2 MiB, the desktop client's loopback-fallback message cap. `timeouts.reconnect_grace` (default `2m`) is how long a session whose client has stopped reaching the relay entirely stays resumable. An alive bridge refreshes the session on every long poll, so it only matters after a total silence; the bridge's own retry window fits under two minutes, and the desktop MTProto layer has already reset its connections after ~30–45 s of silence, so a longer grace mostly pins session slots for clients that will start a fresh session anyway. Raise it if resume-over-slots is preferred (for example for mobile clients that background for minutes). A failing bridge and a session created after the page closed both delete their session promptly, so orphaned slots are bounded by this value. Deployments that still serve Linux desktop proof-of-concept clients built before the 2 MiB WebView message cap should set `limits.max_frame_payload` to `524288` (512 KiB): that bounds both DATA coalescing and per-frame size so no native message exceeds the older helper's 1 MiB cap; the throughput cost is negligible and the default can be restored once those clients are updated. Every `limits` value in a profile is optional. An omitted value inherits the corresponding global value, so the default single profile adds no second quota. Profile values may only lower the global ceiling. With several secrets, the global buckets protect the whole process and each profile's session and stream buckets prevent one secret from consuming more than its configured share. A stream rejected by a capacity or creation-rate limit receives `CLOSE`; other streams and the parent session remain active. Authenticated session creation overload, a temporarily full uplink queue, and an uplink retry racing an in-flight parse all return HTTP 503 with `Retry-After` so the bridge can retry safely; a downlink poll arriving while another is parked simply supersedes it. Official MTProxy has a separate process-level boundary. Its systemd service reads `MTPROXY_WORKERS` and `MTPROXY_MAX_CONNECTIONS` from `/etc/mtproxy/mtproxy.env`, defaulting to `1` and `4096`. The connection value is passed to official MTProxy's per-worker `-C` limit. Keep the relay's `max_streams_global` at or below the intended aggregate backend capacity and increase workers only after one worker is CPU-bound; extra MTProxy workers do not accelerate Caddy, the WebView bridge, or the Go relay. Restarting `tproxy-server` invalidates active carrier sessions: ```bash sudo systemctl restart tproxy-server ``` Existing TCP streams are intentionally not resumed across a relay restart. The client recreates its WebView carrier and logical streams. ## Operations and updates Useful diagnostics contain event classes and counts, not secrets or request URLs: ```bash journalctl -u tproxy-server -u mtproxy -u caddy --since '30 minutes ago' systemctl list-timers refresh-mtproxy-config.timer curl --silent http://127.0.0.1:8888/stats ``` Go profiling routes are disabled by default. Set `"enable_pprof": true` only for a bounded diagnostic window on a host where the loopback admin listener is trusted, then disable it and restart the relay. The supplied service units use `ProtectProc=invisible` and `ProcSubset=pid`, so the public Caddy and relay users cannot inspect other services' process metadata. Stock MTProxy accepts its secret through `-S`, which leaves the value in that process's argument memory. Root and unrestricted host administrators can still inspect it; do not give untrusted users login or unconfined service access to this host. The refresh timer fetches `proxy-multi.conf` daily and restarts MTProxy only when the routing data actually changed. Existing backend streams reconnect through the still-live relay session. The public website remains available when either backend process is down; `/readyz` reports the backend outage. `tproxy-firewall.service` is ordered after and bound to `nftables.service`, so a distribution `nftables` start/reload (whose default `/etc/nftables.conf` begins with `flush ruleset`) re-applies the `inet tproxy_backend` table instead of silently leaving MTProxy's `0.0.0.0` listener exposed. The hosting provider's firewall from step 3 remains the first boundary; this unit is the second. Never enable access logging of raw URIs, request headers, or bodies on Caddy or the relay: the bridge URL carries the derived capability, and the WebSocket carrier's session bearer travels in `Sec-WebSocket-Protocol`. For a relay-code update, upload the new repository and run from its root: ```bash sudo ./deploy/update-relay.sh ``` The updater finds the installed Go toolchain, runs all Go tests, builds and validates a candidate against the installed configuration, keeps the previous binary, installs the candidate atomically, and restarts only `tproxy-server`. It waits for health and, when the old deployment was ready, backend readiness; a failure automatically rolls back to the previous binary. Existing carrier sessions are invalidated; clients must obtain a fresh bridge page and relay session automatically. This script intentionally does not replace configuration, systemd units, Caddy, MTProxy, firewall rules, or public-site files. Running the complete automated installer again preserves an existing site directory but replaces the single-profile config and active Caddyfile, so use it deliberately. When an update includes reviewed changes to the supplied relay or MTProxy unit, install those units separately and then restart the affected services: ```bash sudo install -m 0644 deploy/tproxy-server.service \ /etc/systemd/system/tproxy-server.service sudo install -m 0644 deploy/mtproxy.service \ /etc/systemd/system/mtproxy.service sudo systemctl daemon-reload sudo systemctl restart mtproxy tproxy-server ``` The new unit defaults remain compatible with an existing `mtproxy.env` containing only `MTPROXY_SECRET`; add `MTPROXY_WORKERS` and `MTPROXY_MAX_CONNECTIONS` there only when overriding the defaults. ## Troubleshooting - **Caddy cannot obtain a certificate:** confirm the `A`/`AAAA` records point directly to this host and that both 80 and 443 reach Caddy. Remove a broken AAAA record rather than leaving IPv6 half-configured. - **`/readyz` returns 503:** inspect `systemctl status mtproxy`, then confirm a local TCP connection to `127.0.0.1:2398` and the downloaded files under `/etc/mtproxy`. - **The WebView shows the public site instead of connecting:** hostname and secret must match the server profile exactly; the client derives a different capability for every hostname/secret pair. - **The client remains in its connecting state:** confirm the WebView can load the exact HTTPS hostname, then use the platform-specific client document for native bridge, lifecycle, and fallback diagnostics. - **The public site works but the bridge fails:** inspect only sanitized service status and metrics. Never log bridge URLs or authorization headers. - **Configuration check fails on permissions:** the profiles file must have no group or other permission bits. Use `chmod 0400` and ensure the service receives it via `LoadCredential`. The complete architecture and implementation milestones remain in `PLAN.md`; the normative wire format is in `PROTOCOL.md`.