Repository navigation
Installation
mtg-proxy is a single static Go binary with no runtime dependencies. There are three ways to get one: download a release archive, pull the container image, or build from source. All three build the same source with the same optimisation and linker flags, so they are the same program.
Whichever route you take, read Security before you put the result on a public port, and verify what you downloaded. This is a censorship-circumvention tool; the people who run it are the people least able to survive a substituted binary.
Sections on this page:
- Prebuilt release binaries
- Verifying a download
- Docker
- Building from source
- Where to put things
- Running under systemd
- Checking the installed version
Releases live at
https://github.com/Arman2122/mtg-proxy/releases. Each tag publishes ten
archives, built by .github/workflows/go.yml:
| Archive suffix | GOOS | GOARCH | GOARM | Format |
|---|---|---|---|---|
linux-amd64 |
linux | amd64 | .tar.gz |
|
linux-386 |
linux | 386 | .tar.gz |
|
linux-arm64 |
linux | arm64 | .tar.gz |
|
linux-armv5 |
linux | arm | 5 | .tar.gz |
linux-armv6 |
linux | arm | 6 | .tar.gz |
linux-armv7 |
linux | arm | 7 | .tar.gz |
linux-s390x |
linux | s390x | .tar.gz |
|
windows-amd64 |
windows | amd64 | .zip |
|
windows-386 |
windows | 386 | .zip |
|
windows-arm64 |
windows | arm64 | .zip |
There are no macOS, FreeBSD or OpenBSD release binaries. Go cross-compiles to
all of them, so build from source if that is what you have — but nothing in CI
exercises those targets. The Windows builds are for running the proxy; the WEB
relay runs there too and follows its config file by itself, but Windows has no
SIGHUP, so there is no way to make a reload immediate (see
Running under systemd).
Every binary is built with CGO_ENABLED=0, so it is statically linked and has
no libc dependency. The linux-amd64 archive runs unchanged on glibc and musl
distributions, and inside a scratch container.
Pick the archive for your platform:
curl -sfLO https://github.com/Arman2122/mtg-proxy/releases/download/v1.4.0/mtg-proxy-1.4.0-linux-amd64.tar.gzVerify it before unpacking — see the next section — then unpack:
tar -xzf mtg-proxy-1.4.0-linux-amd64.tar.gzEach archive unpacks into a directory named after itself containing four files:
the mtg-proxy binary (mtg-proxy.exe on Windows), LICENSE, README.md and
example.config.toml. That last one documents every configuration key with its
default; keep it around as a reference and write your own short config rather
than editing it. See Configuration.
Three artifacts accompany the archives on every release. They answer different questions, and only one of them answers "did someone swap this file".
Set the version once. Note that the file names carry the version without the
leading v, while the release URL carries it with:
VERSION=1.4.0PKG=mtg-proxy-${VERSION}-linux-amd64BASE=https://github.com/Arman2122/mtg-proxy/releases/download/v${VERSION}mtg-proxy-<version>-checksums.txt holds one SHA-256 line per published asset,
generated by the release job over the whole dist directory.
curl -sfLO "${BASE}/mtg-proxy-${VERSION}-checksums.txt"sha256sum -c --ignore-missing "mtg-proxy-${VERSION}-checksums.txt"--ignore-missing checks whichever listed files you actually downloaded and
exits non-zero if none matched. This catches a truncated transfer, a stale CDN
object or a version mismatch. It does not catch a deliberate substitution:
the checksums file is served from the same origin as the archive, so anyone who
can replace one can replace the other.
This is the check that means something. Every release asset — the ten archives, the SBOM, and the checksums file itself — is covered by a signed in-toto attestation binding it to this repository, this commit and the workflow run that produced it. It is recorded in Sigstore's public transparency log, so it cannot be quietly withdrawn or backdated. The attestation is minted before the release is created, so a failure means no release rather than a release nobody can check.
It needs the gh CLI and no secret for a public
repository:
gh attestation verify "${PKG}.tar.gz" --repo Arman2122/mtg-proxy --signer-workflow Arman2122/mtg-proxy/.github/workflows/go.yml--signer-workflow is the part that carries the weight. Without it, the check
accepts an attestation from any workflow in the repository that can mint an
OIDC token, which is a materially weaker claim than "this came out of the
release build".
Inside GitHub Actions, gh is already installed; set GH_TOKEN: ${{ github.token }}
on the step.
mtg-proxy-<version>-sbom.spdx.json is an SPDX 2.3 JSON document describing the
Go module graph the release was built from. Use it to answer "is this affected?"
when an advisory lands, without re-deriving the dependency set from a tag.
Two honest caveats about it. The SBOM is generated only on the linux-amd64
build leg, and that step is continue-on-error — a release whose SBOM tool
could not fetch its own installer ships without one rather than not shipping.
If the file is absent from a release, that is why. It is generated on the
build leg (which holds only contents: read) rather than in the publishing job,
specifically so the tool that downloads and executes a script at run time never
runs next to the credentials that mint attestations.
If you package these binaries into a panel, an installer or a distribution:
verify before you unpack, and fail the build when verification fails rather
than warning. An unverified curl | tar in a release pipeline turns one
compromised asset into a root-owned binary on every one of your users' servers.
The image is published to the GitHub Container Registry for linux/amd64 and
linux/arm64:
docker pull ghcr.io/arman2122/mtg-proxy:1.4.0Tags produced by .github/workflows/docker-publish.yml:
| Tag | Means |
|---|---|
1.4.0 |
that exact release |
1.4 |
the newest patch in that minor series |
latest |
the newest tagged, non-prerelease build |
sha-<commit> |
one commit on main, released or not |
Every push to main is published, but only under its sha- tag. latest never
means "whatever landed an hour ago".
The image ships a working default config at /config/config.toml, so it can be
driven entirely from environment variables in the style of the official
telegrammessenger/proxy image:
docker run -d --name mtg -p 443:443 -e SECRET=00112233445566778899aabbccddeeff -e SECRET_HOST=storage.googleapis.com -e MTG_BIND_TO=0.0.0.0:443 ghcr.io/arman2122/mtg-proxy:1.4.0For anything beyond one secret, mount a real config over the bundled default:
docker run -d --name mtg -p 443:443 -v /etc/mtg-proxy/config.toml:/config/config.toml:ro ghcr.io/arman2122/mtg-proxy:1.4.0/config/config.toml is a symlink to /config.toml, so mounting either path
works — the directory form exists for runtimes that can only mount directories.
Verify the image the same way as the archives, using the other workflow name,
and verify a released tag rather than latest:
gh attestation verify oci://ghcr.io/arman2122/mtg-proxy:1.4.0 --repo Arman2122/mtg-proxy --signer-workflow Arman2122/mtg-proxy/.github/workflows/docker-publish.ymlThe image runs as root. That is a deliberate default, not an oversight: dropping
privileges in the image would break two common setups on the next docker pull.
For a process parsing hostile input off port 443 it is worth switching, so add
--user 65534:65534 once you have checked both:
docker run -d --name mtg -p 443:443 --user 65534:65534 -v /etc/mtg-proxy/config.toml:/config/config.toml:ro ghcr.io/arman2122/mtg-proxy:1.4.0-
The config must be readable by uid 65534. A root-owned
0600config is a reasonable thing to do with a file full of user secrets, and it cannot be read after the switch — the proxy exits at startup.0640with a matching group, or0644on a file whose directory is not world-traversable, both work. -
Binding a port below 1024 inside the container. Docker sets
net.ipv4.ip_unprivileged_port_start=0in containers by default, soMTG_BIND_TO=0.0.0.0:443still works. Kubernetes and some other runtimes do not: there, bind a high port inside (MTG_BIND_TO=0.0.0.0:8443) and publish it as 443 outside, or set the sysctl yourself.
If you set usage-state-file, its directory must also be writable by uid 65534.
See Limits-and-Quotas.
The contrib/ directory has two compose deployments worth reading before you
assemble your own: contrib/sni-router puts HAProxy and Caddy in front of the
proxy, and contrib/web-relay overlays the WEB relay onto it. See
WEB-Proxy-Setup.
You need Go 1.26.6 or newer — that is the version pinned in go.mod and in
mise.lock, and the release binaries are built with exactly it.
git clone https://github.com/Arman2122/mtg-proxy.gitcd mtg-proxygo buildgo build with no arguments produces ./mtg-proxy, named from the module path.
To reproduce the release build flags exactly — useful when you want the version
string to say something other than dev:
CGO_ENABLED=0 go build -trimpath -mod=readonly -buildvcs=false -ldflags "-s -w -X main.version=1.4.0" -o mtg-proxy .Cross-compiling is the usual Go story; nothing here needs a C toolchain:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o mtg-proxy-arm64 .The repository is set up for mise, which installs the
pinned Go toolchain and the lint and vulnerability tools from .mise.toml:
mise installmise run buildOther tasks defined there: mise run test (race-enabled go test),
mise run lint (verifies .golangci.yml against its schema, then runs it),
mise run vuln (govulncheck), mise run image (builds the Docker image), and
mise run fmt. There is also mise run static, which adds -a -tags netgo -extldflags '-static'; those flags were dropped from the release and Docker
builds because CGO_ENABLED=0 already selects the pure-Go resolver and there is
no external linker to pass them to, so static is not the build the release
ships.
Nothing in the binary hardcodes a path — every location below is a convention,
chosen to match the examples in contrib/ and in example.config.toml.
Create the service account first, because the file modes below all reference its group:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin mtg-proxysudo install -m 0755 mtg-proxy /usr/local/bin/mtg-proxysudo install -d -m 0750 -o root -g mtg-proxy /etc/mtg-proxyThen write your config to /etc/mtg-proxy/config.toml and give it the same
owner and group:
sudo chown root:mtg-proxy /etc/mtg-proxy/config.tomlsudo chmod 0640 /etc/mtg-proxy/config.tomlThe config holds every user's secret in plaintext. Anyone who can read it can
impersonate any of your users, which is why it is 0640 rather than 0644 and
why /etc/mtg-proxy is not world-traversable — the service reads it through
group membership, and nothing else on the box can.
The WEB relay's signing key is separate and stricter. generate-web-key writes
32 bytes at mode 0600 and refuses to overwrite an existing file, because a key
regenerated by accident invalidates every live WEB session. It lands as
root:root 0600, which the relay cannot read, so hand it over afterwards:
sudo mtg-proxy generate-web-key /etc/mtg-proxy/web.keysudo chown mtg-proxy /etc/mtg-proxy/web.keyIf you use usage-state-file to persist quota counters across restarts, give it
a directory the service owns — /var/lib/mtg-proxy/usage.json pairs with the
StateDirectory= line in the unit below, which systemd creates owned by the
service user. The file is rewritten atomically about every 30 seconds and on
shutdown.
Check the config before you enable anything:
mtg-proxy doctor /etc/mtg-proxy/config.tomldoctor checks connectivity, clock skew, fronting reachability and the
SNI-to-DNS match. If you chain through a SOCKS5 proxy and direct egress is not
expected to work, add --skip-native-check. See Troubleshooting.
The repository does not ship a unit file. The one below is written for this
page against the commands and signal handling in the source; it is a starting
point, not something CI exercises. Adjust paths to match your install, and check
systemctl status after the first start rather than assuming.
It assumes the mtg-proxy user and the file ownership from the previous
section. /etc/systemd/system/mtg-proxy.service:
[Unit]
Description=mtg-proxy MTProto proxy
Documentation=https://github.com/Arman2122/mtg-proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mtg-proxy
Group=mtg-proxy
ExecStart=/usr/local/bin/mtg-proxy run /etc/mtg-proxy/config.toml
# Not decoration. If the public socket ever breaks for good, the proxy exits
# non-zero rather than run on looking healthy while serving nobody, and this
# line is what brings it back. A [[listener]] breaking does not exit it.
Restart=on-failure
RestartSec=5s
# Binding :443 without running the whole process as root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Becomes /var/lib/mtg-proxy, owned by the service user. Point
# usage-state-file at /var/lib/mtg-proxy/usage.json to use it.
StateDirectory=mtg-proxy
StateDirectoryMode=0750
# Every connection is a socket, and the defaults are lower than the
# throttle and concurrency ceilings the proxy will happily reach.
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now mtg-proxyThe proxy logs JSON to stdout, so journald collects it with no extra configuration:
journalctl -u mtg-proxy -fThere is deliberately no ExecReload= line above, and this is the part
people get wrong. mtg-proxy run handles SIGINT and SIGTERM for shutdown
and nothing else — it does not reload on SIGHUP. Sending one terminates
the service under the default disposition. Secrets are re-read over the
management API instead:
curl -fsS -X POST http://127.0.0.1:9090/reloadIf you want systemctl reload mtg-proxy to do that, and you have api-bind-to
set, add the equivalent line yourself:
ExecReload=/usr/bin/curl -fsS -X POST http://127.0.0.1:9090/reloadIf you set api-token, that line needs the header too
(-H "Authorization: Bearer …"), and the unit file then holds the token — so
keep the unit 0640 as well, or use EnvironmentFile= for it.
See Multi-User-and-API for what a reload does and does not swap.
mtg-proxy web-relay is a separate process reading the same config file. It
watches that file and applies a changed [secrets] table by itself within a
few seconds, so nothing has to reload it — but it is the one process that
does accept SIGHUP, which applies a change immediately rather than on the
next look. Copy the unit above, change ExecStart, and add the reload line:
ExecStart=/usr/local/bin/mtg-proxy web-relay /etc/mtg-proxy/config.toml
ExecReload=/bin/kill -HUP $MAINPIDIn the relay's copy, drop the AmbientCapabilities line and set the bounding set
to nothing at all — an empty CapabilityBoundingSet= clears it, whereas deleting
the line restores the full default set:
CapabilityBoundingSet=The relay binds a loopback high port behind your TLS front proxy, never :443,
so it needs no capability. Validate its configuration before enabling it:
mtg-proxy web-relay --check /etc/mtg-proxy/config.tomlThe signal is Unix-only. The Windows build registers no reload signal at all —
Go defines SIGHUP there but nothing ever delivers it, so advertising one would
advertise something that silently never happens. The file watcher runs
everywhere, so a Windows relay picks up a new secret set by itself; it just
cannot be hurried. See WEB-Proxy-Setup.
The version is a flag, not a subcommand:
mtg-proxy --version-v is the short form. There is no mtg-proxy version subcommand — it exits
with error: unexpected argument version.
A binary from a release archive prints something like:
1.3.0 (go1.26.6: 2026-09-04T19:12:46Z on , modules checksum bp+JucsvJcQRqOi6SNXUgC/v/wnn+FnZHs9TiVYOrSI=)
Read it left to right: the release version, the Go toolchain that built it, a timestamp, the commit, and a checksum over the module graph.
Two fields look broken on a release binary and are not. The commit is empty,
and the timestamp is the moment you ran the command rather than a build date.
Both follow from the release workflow passing -buildvcs=false: with VCS
stamping off there is no vcs.revision or vcs.time to print, so the commit
comes out blank and the date falls back to now. Only the first token and the
module checksum identify the build.
A binary you built yourself in a git checkout does show a commit, plus a
[dirty] marker when the tree has uncommitted changes.
For the container:
docker run --rm ghcr.io/arman2122/mtg-proxy:1.4.0 --versionExpect the image's output to differ in those same two fields — the Dockerfile
builds from a context that includes .git and does not pass -buildvcs=false,
so the image stamps a real commit and build time where the tarball leaves them
blank. The leading version token is the one to compare.
- Quick-Start — generate a secret, write a minimal config, hand out links.
- Configuration — every key, and the TOML ordering rule that bites everyone.
- Connection-Types — FakeTLS, obfuscated2 listeners, and WEB.
- Security — what to harden before this is reachable from the internet.
Start here
Running it
Operating it
When it goes wrong
Repository · Releases · Discussions · Report a vulnerability