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.
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.
- a dedicated lowercase hostname such as
proxy.example.comthat 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
sudoon 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.
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.comGenerate the client-facing secret on your own computer:
openssl rand -hex 16That produces 32 lowercase hexadecimal characters. Keep the exact value: it is entered in every client that uses this server and passed to the installer.
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.
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/tcpFrom 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-siteFor 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:3000The 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:
- 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;
- verifies the archive for pinned MTProxy commit
f36d8af769ffaeac36978d38c2c0f6d1104c2137, builds it unchanged as the unprivilegedmtproxyuser, and installs the result as root; - downloads the official MTProxy secret and routing configuration over HTTPS;
- runs all Go tests and installs
/usr/local/bin/tproxy-server; - installs the operator-supplied static site without overwriting an existing
/srv/tproxy-site, or configures the private site application; - creates mode-restricted configuration and a systemd credential for the WEB secret;
- installs the backend firewall, relay, MTProxy, refresh timer, and Caddy units; and
- 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.
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_backendExpected 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 -datesAlso 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 8081Check 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.
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.mdfor its build and test instructions. - The planned iOS proof of concept uses a process-wide
WKWebViewcarrier. SeeIOS.mdfor its design and lifecycle plan.
These are client implementations of the same WEB proxy protocol, not separate server modes.
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.
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-serverInstall 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.
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.
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-serverExisting TCP streams are intentionally not resumed across a relay restart. The client recreates its WebView carrier and logical streams.
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/statsGo 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.shThe 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-serverThe 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.
- Caddy cannot obtain a certificate: confirm the
A/AAAArecords point directly to this host and that both 80 and 443 reach Caddy. Remove a broken AAAA record rather than leaving IPv6 half-configured. /readyzreturns 503: inspectsystemctl status mtproxy, then confirm a local TCP connection to127.0.0.1:2398and 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 0400and ensure the service receives it viaLoadCredential.
The complete architecture and implementation milestones remain in PLAN.md; the
normative wire format is in PROTOCOL.md.