Skip to content

Installation

Arman2122 edited this page Sep 5, 2026 · 3 revisions

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

Prebuilt release binaries 📦

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

Verify it before unpacking — see the next section — then unpack:

tar -xzf mtg-proxy-1.4.0-linux-amd64.tar.gz

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

Verifying a download 🔎

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.0
PKG=mtg-proxy-${VERSION}-linux-amd64
BASE=https://github.com/Arman2122/mtg-proxy/releases/download/v${VERSION}

1. Checksums

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.

2. Signed build provenance

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.

3. The SBOM

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 redistribute mtg-proxy

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.

Docker 🐳

The image is published to the GitHub Container Registry for linux/amd64 and linux/arm64:

docker pull ghcr.io/arman2122/mtg-proxy:1.4.0

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

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

Running the container as a non-root user

The 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 0600 config 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. 0640 with a matching group, or 0644 on 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=0 in containers by default, so MTG_BIND_TO=0.0.0.0:443 still 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.

Building from source

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.git
cd mtg-proxy
go build

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

With mise

The repository is set up for mise, which installs the pinned Go toolchain and the lint and vulnerability tools from .mise.toml:

mise install
mise run build

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

Where to put things

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-proxy
sudo install -m 0755 mtg-proxy /usr/local/bin/mtg-proxy
sudo install -d -m 0750 -o root -g mtg-proxy /etc/mtg-proxy

Then 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.toml
sudo chmod 0640 /etc/mtg-proxy/config.toml

The 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.key
sudo chown mtg-proxy /etc/mtg-proxy/web.key

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

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

Running under systemd

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.target
sudo systemctl enable --now mtg-proxy

The proxy logs JSON to stdout, so journald collects it with no extra configuration:

journalctl -u mtg-proxy -f

Reloading

There 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/reload

If 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/reload

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

A second unit for the WEB relay

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 $MAINPID

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

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

Checking the installed version

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

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

Next steps

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

Clone this wiki locally