Skip to content

Repository files navigation

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, and an iOS client plan in 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

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 for the normative wire contract and PLAN.md for the architecture, limits, implementation rationale, and remaining proof-of-concept work. PUBLIC_SITE.md defines the operator-owned website extension points.

The reference deployment layout is:

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=<valid capability> 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:

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:

dig +short A proxy.example.com
dig +short AAAA proxy.example.com

Generate the client-facing secret on your own computer:

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 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.

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:

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:

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:

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:

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:

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:

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 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

Also confirm the local-only ports are unreachable externally. These commands should time out or fail:

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:

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:

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:

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 for its build and test instructions.
  • The planned iOS proof of concept uses a process-wide WKWebView carrier. See 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:

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, 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:

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:

{
  "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:

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:

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:

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:

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.

About

Proof-of-concept WEB proxy server for Telegram

Resources

Stars

49 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages