Sign inSign up

andromedi4/mtproto.zig

By andromedi4

Updated 11 days ago

Telegram MTProto proxy with FakeTLS masking, configured entirely through environment variables

Image
Networking
0

1.5K

andromedi4/mtproto.zig repository overview

mtproto.zig

Version Image Size Docker Pulls Platforms License

Telegram MTProto proxy with FakeTLS masking, built from source from sleep3r/mtproto.zig and pinned to a release tag.

The container renders config.toml from environment variables at startup, so there is no config file to generate, template, or mount. A static binary on debian:bookworm-slim, no runtime dependencies beyond curl and CA certificates.

Tags

TagContents
1.15.0Immutable — always this upstream release
latestMoves with each new release

Platforms: linux/amd64, linux/arm64.

Quick start

services:
  mtproto:
    image: andromedi4/mtproto.zig:1.15.0
    container_name: mtproto-zig
    restart: unless-stopped
    environment:
      MTP_TLS_DOMAIN: "rutube.ru"       # domain the traffic is masked as
      MTP_SECRET: ""                    # empty -> generated and persisted in the volume
      MTP_PRINT_LINKS: "1"              # print tg:// links to the log on startup
      MTP_LINK_HOST: "proxy.example.com"
    volumes:
      - mtproto-state:/var/lib/mtproto
    ports:
      - "443:443"

volumes:
  mtproto-state:
docker compose up -d
docker compose logs -f mtproto

With MTP_PRINT_LINKS=1 the log contains a ready-to-share tg://proxy?... link. Note that the link embeds the secret, so treat that log as sensitive and turn the flag back off once you have copied it.

MTP_TLS_DOMAIN cannot be changed after you hand out links — it is baked into the secret clients use.

Environment variables

All are optional.

VariableDefaultDescription
MTP_PORT443Listen port
MTP_TLS_DOMAINrutube.ruFakeTLS masking domain. Immutable once links are distributed
MTP_SECRET(auto)32-hex secret for user1. Empty → generated and persisted
MTP_SECRET_FILEPath to a file holding the secret (Docker/Swarm secrets)
MTP_USERSMultiple users: alice:HEX32,bob:HEX32. Overrides MTP_SECRET. An entry without : gets a random secret that is not persisted — see below
MTP_USER_MAX_IPSConcurrent unique-network quota per user: 4 for everyone, or alice:4,bob:2. Empty = unlimited (details)
MTP_USE_MIDDLE_PROXYtrueMiddleProxy — required for media on non-Premium accounts
MTP_METRICStruePrometheus endpoint on :9400
MTP_MASKtrueForward active probes instead of dropping them
MTP_FAST_MODEtrueFast path for proxied traffic
MTP_MAX_CONNECTIONS512 (proxy default)[server].max_connections. Raised automatically when the WEB proxy is on (why)
MTP_MASK_TARGETOverride the active-probe forward target
MTP_MASK_PORT443Port of that backend
MTP_MIDDLE_PROXY_NAT_IPHost public IPv4 — fixes media behind Docker bridge NAT
MTP_PUBLIC_IPHost/IP advertised in generated links
MTP_TAG32-hex promotion tag from @MTProxybot
MTP_LINK_HOST(autodetect)Host shown in printed links — set it explicitly, autodetect from inside a container is unreliable
MTP_PRINT_LINKS01 → print tg:// links (with secrets) to the log
MTP_CONFIG/etc/mtproto-proxy/config.tomlRendered config path
MTP_RENDERautoforce → re-render even if a config already exists
MTP_STATE_DIR/var/lib/mtprotoWhere the auto-generated secret persists
MTP_ROLEproxyrelay → run the WEB relay instead of the proxy (see below)
MTP_WEB_ENABLEDfalseTurn on WEB proxy. Needed by both the proxy and the relay
MTP_WEB_ONLYfalseAnswer MTProto for the relay and nobody else — every ee/dd link already handed out stops working (see below)
MTP_WEB_DOMAINHostname in tg://webproxy links. Immutable once handed out
MTP_WEB_HOST / MTP_WEB_PORT127.0.0.1 / 8081Relay listener
MTP_WEB_BACKEND127.0.0.1:MTP_PORTProxy the relay dials on every stream
MTP_WEB_CLIENT_IP_HEADERx-forwarded-forHeader carrying the real client IP (cf-connecting-ip behind Cloudflare)
MTP_WEB_RELAY_SOURCESIP literals of relays outside this container's netns

Changing any variable takes effect on docker compose up -d — the container is recreated and the config re-rendered.

Unprivileged by default

Since 1.15.0 the container runs as uid 10001, not root. It still binds :443 — the binary carries cap_net_bind_service, because Docker's default bounding set grants NET_BIND_SERVICE to root only; an unprivileged process needs the capability on the file itself.

Upgrading from 1.14.0 or earlier needs nothing if MTP_SECRET is set — the state volume is never written in that case. If it is empty, the persisted secret lives in a volume created by a root-era image and the new uid cannot read it. The container refuses to start rather than mint a replacement, because a fresh secret would silently invalidate every link already handed out:

mtproto-proxy: ERROR /var/lib/mtproto/user1.secret exists but is unreadable as uid 10001.

One-off fix on the host:

docker compose down
docker run --rm -v <volume>:/s --user 0:0 debian:bookworm-slim chown -R 10001:10001 /s
docker compose up -d

Fresh installs need no fix: a new volume inherits ownership from the image, where the directory already belongs to mtproto.

Secrets

  • Pinned: set MTP_SECRET=$(openssl rand -hex 16). Links stay stable across hosts.
  • Generated: leave it empty. The secret is created on first start and persisted in /var/lib/mtproto — it survives recreation, but is tied to that volume. Mount the volume or you will hand out new links after every docker volume rm.
  • File-based: point MTP_SECRET_FILE at a mounted Docker/Swarm secret.

Persistence applies only to the single-user case. When MTP_USERS is set, any entry written without a secret gets a freshly generated one on every start and it is never saved, so those links break after each restart — silently. Give every user an explicit secret:

      MTP_USERS: "alice:0123...,bob:4567..."   # 32 hex chars each

Removing a user from MTP_USERS and running docker compose up -d revokes just that link.

Added upstream in 1.12.0. Caps how many distinct client networks may use one link at the same time:

      MTP_USER_MAX_IPS: "4"                # same cap for every user
      # MTP_USER_MAX_IPS: "alice:4,bob:2"  # ...or per user; anyone unlisted stays unlimited

What counts is a network — one IPv4 address, or one IPv6 /64 — not a socket, because a single Telegram client already opens several of those ([access.user_max_conns] is the knob for sockets). The /64 matters: SLAAC hands one prefix to a whole home LAN, and privacy extensions rotate the host part inside it, so keying on the full address would burn the quota in minutes.

Counting is concurrent, never historical: a network holds its slot only while it has a live connection, so users roam freely — they just cannot be in N+1 networks at once. The extra connection is refused, the client is not told why, and it keeps retrying.

Size it generously. One phone can legitimately need more than one slot:

  • on a dual-stack host, its IPv4 address and its IPv6 /64 are two separate networks;
  • a Wi-Fi ↔ mobile handover does not close the old sockets — they linger until idle_timeout_sec, so the abandoned network keeps its slot for up to ~2 minutes;
  • some carriers hand out a fresh /64 on every reattach.

3–4 per device is a sane floor. 1 will lock a user out of their own link.

What it is not: a defence against resale. There is no eviction — the first N networks win, so whoever the link was passed to can take the slots simply by connecting first. CGNAT also hides thousands of subscribers behind one IPv4, so a whole customer base on one carrier fits in a single slot. A link that really leaked needs a new secret.

With the WEB proxy, unless MTP_WEB_MASK_BACKEND is set, the relay hands every browser to the proxy as 127.0.0.1. Those connections are exempt from the quota rather than crowding into one shared slot — otherwise they would evict the user's real devices while limiting nothing.

Three metrics appear, but only for users that have a quota (the others are not tracked at all, and a 0 would read as "nobody connected" rather than "no limit"):

MetricMeaning
mtproto_user_unique_ips_activenetworks holding connections right now
mtproto_user_unique_ips_maxthe configured quota
mtproto_user_ip_limit_refused_totalconnections refused because the quota was full

The counter earns its place: a gauge sitting at its maximum is the normal state of a user at quota, so it cannot distinguish "sized correctly" from "turning away a real device right now".

Read at startup — docker compose up -d applies a change, SIGHUP does not. A name that is not in MTP_USERS produces a startup warning instead of being silently ignored.

Bring your own config

Mount a config.toml at /etc/mtproto-proxy/config.toml and the entrypoint uses it as-is, ignoring the MTP_* variables. Set MTP_RENDER=force to render from the environment anyway.

Hardware AES

The two architectures ship different CPU baselines, and both choices are deliberate.

ArchZig -DcpuAES
amd64x86_64+aes+avxHardware — AVX shipped on Intel since Sandy Bridge and AMD since Bulldozer, both 2011
arm64baselineSoftware — runs on any ARMv8

Hardware AES is off on arm64 on purpose. ARMv8 crypto extensions are absent on every Raspberry Pi before the 5 (Pi 3 is Cortex-A53, Pi 4 is Cortex-A72), and a build that uses them dies there with Illegal instruction inside std.crypto.aes.armcrypto on the first TLS handshake. The cost is the AES backend is software-only warning at startup and heavier CPU use for MiddleProxy video.

On arm64 hardware that does have the extensions (Graviton, Ampere, Pi 5), or on a pre-2011 x86 CPU that lacks AVX, rebuild from source with a different MTP_CPU — see below.

WEB proxy (Telegram Desktop 7.1+)

A fourth proxy type added upstream in 1.11.0. The client opens https://<domain>/ in a hidden WebView and tunnels MTProto over a WebSocket from that page. Unlike FakeTLS — which imitates a TLS session to someone else's domain — this is a genuine HTTPS session with a real site that serves real HTML if you visit it in a browser.

Desktop 7.1+ only. Mobile clients have no such proxy type, so FakeTLS links stay primary.

TLS is not terminated by this image — it ships no certificates and no ACME client. Put Traefik, nginx, or a CDN in front with a valid certificate for MTP_WEB_DOMAIN, forwarding the client address in X-Forwarded-For; without that every WEB user reaches Telegram as the terminator's own address.

The relay is a second container from this same image with MTP_ROLE=relay, sharing the proxy's network namespace so it reaches the proxy over loopback — and the proxy sees the relay as loopback, which is what makes it trusted (fake_tls_only refuses untrusted relays). The relay then has no address of its own: port 8081 belongs to the proxy container, so route the WEB domain to <proxy-container>:8081.

services:
  mtproto:
    image: andromedi4/mtproto.zig:1.15.0
    environment:
      MTP_USERS: "alice:0123...,bob:4567..."   # pin them: the relay needs the same secrets
      MTP_WEB_ENABLED: "true"
      MTP_WEB_DOMAIN: "web.example.com"

  web-relay:
    image: andromedi4/mtproto.zig:1.15.0
    depends_on: [mtproto]
    network_mode: "service:mtproto"
    environment:
      MTP_ROLE: "relay"
      MTP_WEB_ENABLED: "true"
      MTP_WEB_DOMAIN: "web.example.com"
      MTP_WEB_HOST: "0.0.0.0"
      MTP_USERS: "alice:0123...,bob:4567..."   # identical to the proxy's
      MTP_METRICS: "false"                     # :9400 is already taken in the shared netns

Both roles render the same config from the same variables, so the users must match — pin the secrets or the two disagree on every restart.

Links appear alongside the regular ones under MTP_PRINT_LINKS=1, as tg://webproxy?server=<domain>&secret=dd<32hex> — same per-user secret as the FakeTLS link but prefixed dd instead of ee, and port 443 is implicit.

MTP_WEB_DOMAIN is as immutable as MTP_TLS_DOMAIN: the bridge capability is an HMAC over the canonical hostname, so changing it invalidates every WEB link already issued.

WEB-only mode

Added upstream in 1.14.0, for deployments where the IP gets blocked the moment a Telegram client connects to it directly. MTP_WEB_ONLY=true stops offering that door: the data plane answers MTProto for the relay — recognised by the address accept() reports, never by anything the client sends — and hands everyone else to the masking backend on exactly the code path a wrong secret takes. A probe cannot tell the proxy from the site it fronts.

The price is not negotiable:

  • every ee/dd link already handed out stops working, permanently. The links stay well-formed; nothing on the other end answers them any more.
  • only Telegram Desktop 7.1+ can connect — no mobile client speaks WEB.
  • MTP_WEB_MAX_SESSIONS becomes the real user ceiling: everyone now arrives via the relay.

The entrypoint checks the preconditions before the proxy starts:

ConditionWhat happens
MTP_WEB_ENABLED not truewarning, MTP_WEB_ONLY ignored — otherwise the relay would not be trusted either and every connection would be masked
MTP_WEB_DOMAIN emptyrefuses to start: no WEB link can be built and direct MTProto is masked, so nothing could connect
MTP_MASK=falsewarning: refusals become bare closes, and under WEB-only that is 100% of public traffic — a cleaner probe signal than answering MTProto ever was

Direct ee links are no longer printed under MTP_PRINT_LINKS=1 — a link that silently never connects is worse than no link. Read at startup only; SIGHUP does not pick it up.

Watch mtproto_web_only_masked_total: these connections are masked rather than dropped, so that counter is the only place they are visible at all.

If MTP_MASK_TARGET points at a third-party domain, turn it into a local cover site first — under WEB-only it stops receiving the occasional probe and starts receiving all public traffic. The proxy warns about this at startup; the warning is literal about what counts as local (127.0.0.1, localhost, ::1), so a cover site addressed by container name on the same host trips it harmlessly.

Connection budget

One WEB session can pin max_sessions × (max_streams + 1) proxy connections — 1056 at the defaults — while [server].max_connections defaults to 512. So whenever MTP_WEB_ENABLED=true, the entrypoint sizes max_connections to that worst case plus 512 for direct clients, and says so in the log:

mtproto-proxy: max_connections=1568 (WEB worst case 32x33 + 512 for direct clients; set MTP_MAX_CONNECTIONS to override)

Under MTP_WEB_ONLY the formula is the same but the +512 changes meaning — there are no direct clients left, and the headroom covers the masked connections their refused handshakes turn into (the log says for masked probes).

It tracks MTP_WEB_MAX_SESSIONS/MTP_WEB_MAX_STREAMS, so lowering those lowers it too, and MTP_MAX_CONNECTIONS overrides the calculation entirely.

The proxy then applies its own RAM-safe clamp (~4 MiB per connection) and logs it:

warning(config): auto-clamping max_connections from 1568 to 1187 (host has 7936 MiB RAM, ...)

That keeps a small host out of swap, but it can land below the WEB worst case — and then WEB sessions can eat the whole budget out from under direct clients. Raising MTP_MAX_CONNECTIONS does not help (the clamp cuts it back); lower MTP_WEB_MAX_SESSIONS / MTP_WEB_MAX_STREAMS instead. Rule of thumb: if the clamped number is below sessions × (streams + 1), the WEB caps are too big for that machine.

Metrics

With MTP_METRICS=true (the default), Prometheus metrics are served on :9400. Keep that port on loopback rather than publishing it:

    ports:
      - "127.0.0.1:9400:9400"

A matching healthcheck:

    healthcheck:
      test: ["CMD-SHELL", "curl -sf -o /dev/null --max-time 3 http://127.0.0.1:9400/metrics"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s

Behind a reverse proxy

The proxy speaks raw FakeTLS, so a fronting proxy must do TLS passthrough and route by SNI — terminating TLS breaks it. The client's SNI equals MTP_TLS_DOMAIN, so the routing rule must match that exact name. Drop the 443 port mapping and put the container on the proxy's network.

Traefik:

    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik_net"
      - "traefik.tcp.routers.mtproto.rule=HostSNI(`proxy.example.com`)"
      - "traefik.tcp.routers.mtproto.entrypoints=websecure"
      - "traefik.tcp.routers.mtproto.tls.passthrough=true"
      - "traefik.tcp.routers.mtproto.service=mtproto-svc"
      - "traefik.tcp.services.mtproto-svc.loadbalancer.server.port=443"

Media not loading

On a Docker bridge network the proxy may fail to detect its own outbound IP, which breaks media for non-Premium users. Set MTP_MIDDLE_PROXY_NAT_IP to the host's public IPv4, or run with network_mode: host (then drop ports). Telegram voice calls are not supported by the proxy.

MTProto proxies hold many concurrent connections:

    pids_limit: 65535
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
      nproc: 65535

Building it yourself

docker build \
  --build-arg MTP_VERSION=1.15.0 \
  --build-arg MTP_CPU=baseline \
  -t mtproto-zig .

MTP_CPU takes any Zig CPU spec: baseline for portable software AES anywhere, baseline+aes for arm64 with crypto extensions, x86_64+aes+avx for x86. If the running CPU is older than the chosen baseline, the process aborts with Illegal instruction at startup.

Tag summary

Content type

Image

Digest

sha256:87efa7285

Size

36.4 MB

Last updated

11 days ago

docker pull andromedi4/mtproto.zig