github sleep3r/mtproto.zig v1.12.0

latest release: v1.13.0
4 hours ago
🇷🇺 Что нового (RU)

Коротко

v1.12.0 добавляет лимит уникальных IP на ссылку[access.user_max_ips], ответ на #381. Одна ссылка может работать одновременно максимум из N разных клиентских сетей.

Плюс три бага в sing-box egress, найденные в живой отладочной сессии. Каждый маскируется под что-то другое: egress во всех трёх случаях рапортует, что он поднялся. Один блэкхолил все объёмные передачи, второй убивал резолвинг DNS на всём хосте, третий загонял прокси в перезапускающийся цикл.

И задел под #374: пути, которые уже объявляли себя мультидистрибутивными, но не работали. Поддержки pacman/dnf в инсталляторе это не добавляетmtbuddy install по-прежнему требует Debian/Ubuntu, просто теперь говорит об этом честно.

⚠️ Если у вас настроен mtbuddy setup egress — обычного mtbuddy update недостаточно, см. раздел «Важно для уже установленных хостов».

Лимит уникальных IP на ссылку

[access.user_max_ips]   # алиас: [access.users_max_unique_ips]
user1 = 4
user2 = 2

Отсутствие ключа или 0 — без ограничений; потолок 256. Меняется рестартом, как и соседние [access.user_max_conns] / [access.user_expirations] — SIGHUP теперь прямо пишет об этом в лог, раньше молчал.

[access.user_max_conns] эту задачу выразить не может: он считает сокеты, а один клиент Telegram сам открывает несколько. Здесь считаются сети:

  • один IPv4-адрес — одна сеть. Форма ::ffff:a.b.c.d, которую отдаёт dual-stack-слушатель, схлопывается в тот же ключ, иначе один и тот же телефон занимал бы два слота в зависимости от того, какой слушатель его принял;
  • нативный IPv6 считается по /64. Privacy extensions постоянно крутят host-часть, а SLAAC выдаёт одному LAN один /64 — ключ по полному /128 сжёг бы квоту за минуты.

Счёт строго конкурентный, никакой истории: сеть занимает слот ровно пока у неё есть хотя бы одно живое соединение. Роуминг между сетями бесплатный — нельзя лишь держать больше N одновременно. Соединение из N+1-й сети закрывается с причиной user ip limit; клиенту причину не сообщают, и он продолжит переподключаться.

Как выбирать N — с запасом. Один телефон легко требует больше одного слота:

  • на dual-stack-хосте его IPv4-адрес и его IPv6 /64 — это две разные сети;
  • при переходе Wi-Fi ↔ мобильный интернет старые сокеты не закрываются самим переходом: они висят до idle_timeout_sec, так что брошенная сеть держит слот ещё до ~2 минут;
  • некоторые операторы выдают свежий /64 на каждый реаттач.

3–4 на устройство — разумная отправная точка. N = 1 будет отказывать собственному владельцу.

Чем это НЕ является — защитой от перепродажи. Вытеснения нет: первые N сетей выигрывают, так что тот, кому ссылку передали, может занять слоты просто потому, что подключился раньше. Плюс CGNAT прячет тысячи абонентов за одним IPv4 — вся клиентская база реселлера на одном операторе уложится в один слот. Это заслон от бытового расшаривания; по-настоящему утёкшую ссылку лечит только ротация секрета.

WEB-прокси. Без [web].mask_backend релей отдаёт прокси каждый браузер как 127.0.0.1. Такие соединения из квоты исключены, а не сваливаются в один общий слот — иначе они вытесняли бы реальные устройства пользователя, ничего при этом не ограничивая. С PROXY-protocol-терминатором peer_addr — настоящий адрес браузера, и квота работает как обычно.

Наблюдаемость. Три новых ряда, только для пользователей с квотой (у остальных нет отслеживания вообще, и 0 читался бы как «никто не подключён», а не как «без лимита»):

метрика что показывает
mtproto_user_unique_ips_active сколько сетей держат соединения сейчас
mtproto_user_unique_ips_max настроенная квота
mtproto_user_ip_limit_refused_total сколько соединений отвергнуто из-за полной квоты

Счётчик нужен именно потому, что gauge на максимуме — это штатное состояние пользователя на квоте, и по нему нельзя отличить «настроено верно» от «прямо сейчас отшиваем живое устройство». Буфер тела метрик поднят с 32 до 64 KiB — новые ряды удваивают пер-юзерную часть выдачи.

Имя в [access.user_max_ips], которого нет в [access.users], теперь даёт предупреждение при старте: молча не применять лимит — худшее направление отказа.

Если секция не настроена, для развёртывания не меняется ничего: разбор конфига, путь допуска, вывод метрик и потребление памяти остаются прежними.

Три бага в sing-box egress

Объёмные передачи уходили в никуда

У TUN не было mtu. Без него sing-box ≥1.12 оставляет Linux-TUN на 65535 (в ≤1.11.x было 9000) — оба значения далеко за пределами пригодного размера кадра.

Это не размер на проводе. С TUN-инбаундом sing-box терминирует TCP в собственном netstack'е и передиаливает исходящее обычным сокетом ядра — ничего не инкапсулируется и запас под внешний TLS не нужен. Это размер кадра kernel↔netstack, то есть MSS, который локальное ядро согласует на DC-соединении через sbx0. Уточнение существенное: очевидное прочтение отправляет отладчика ковырять MSS-клампинг, где ничего нет.

На дефолте хендшейк и коннект к DC (мелкие) проходят, а каждая передача теряется: setup egress печатает «up», прокси после этого возит 0 B/s, и выглядит это как мёртвый апстрим.

Теперь эмитится "stack":"gvisor","mtu":1400 — пара, замеренная на живом хосте как реально пропускающая объёмный трафик. Она же оказалась ровно тем предикатом, который включает в sing-box GSO-offload на TUN (Stack == "gvisor" && tunMTU > 0 && tunMTU < 49152 — нужны оба условия, на дефолтном MTU offload не включается), а по бенчмаркам самого sing-box gvisor не медленнее system-стека ни на одном MTU.

Адрес TUN убивал DNS на всём хосте

sing-tun в NativeTun.start() безусловно вызывает setSearchDomainForSystemdResolved() — соседний AutoRoute-гейт only-Android, так что auto_route: false его не останавливает. Если DNS-серверы явно не заданы, он синтезирует один из адреса интерфейса:

if len(t.options.Inet4Address) > 0 && HasNextAddress(t.options.Inet4Address[0], 1) {
    dnsServer = append(dnsServer, t.options.Inet4Address[0].Addr().Next())
}
if len(dnsServer) == 0 { return }
go func() {
    resolvectl domain <tun> "~."
    resolvectl default-route <tun> true
    resolvectl dns <tun> <dnsServer>
}()

При 172.19.0.1/30 следующий адрес — 172.19.0.2 — попадает внутрь префикса. А ~. это route-only домен, матчащий любое имя, так что resolved ставит scope sbx0 выше eth0 и отправляет 100% запросов на 172.19.0.2:53, где никто не слушает. Резолвинг на хосте умирает целиком.

При /32 следующий адрес выпадает из префикса, Inet6Address в конфиге нет, dnsServer остаётся пустым — и функция выходит до первой команды resolvectl.

Почему именно адрес: EXP_DisableDNSHijack — библиотечное поле без JSON-тега, конфигом это не выключить. А «откатить потом из route-скрипта» гонялось бы с их detached-горутиной. И /30 здесь не нужен ни для чего: auto_route выключен, так что sing-tun не ставит маршрутов и выведенный из адреса шлюз никуда не применяется, а наш дефолтный маршрут — dev sbx0, ему nexthop не нужен.

Баг зависел от хоста — нужен resolvectl в PATH и работающий systemd-resolved — поэтому и всплыл только в поле.

Осиротевший ExecStartPre загонял прокси в цикл

setup tunnel пишет в mtproto-proxy.service строку ExecStartPre=+/usr/local/bin/setup_tunnel.sh. Запуск setup egress после этого ломается двумя разными способами:

  • ветка ухода AmneziaWG-пула на покой удаляет этот скрипт → systemd валит юнит с 203/EXEC и перезапускает по кругу совершенно здоровый прокси;
  • там, где скрипт уцелел (один туннель, без пула), он на каждом старте прокси переуказывает таблицу 200 на awg0 и уводит маршрут у sbx0.

Drop-in egress.conf теперь дополнительно пишет пустой ExecStartPre=, сбрасывающий список. Место выбрано намеренно: вручную дописанная строка стирается следующим setup egress, а drop-in переживает. Маршрутизацию sbx0 ставит ExecStartPost юнита sing-box, а у дефолтного (нетуннельного) юнита ExecStartPre нет вовсе — так что везде ещё этот сброс никого не трогает.

Обратное направление уже было корректным: setup tunnel сносит egress.conf, когда отправляет sing-box-egress на покой, а удаление последнего туннеля через меню возвращает юнит к дефолтному.

Важно для уже установленных хостов

mtbuddy update не чинит ни один из трёх egress-багов. Все четыре файла, которые пишет setup egress, создаются один раз — при настройке, — а update их не перегенерирует (единственные его in-place-починки — это TCPMSS-скрипт и юнит nfqws из v1.11.0). После обновления sbx0 по-прежнему поднимется с /30, stack:system и MTU по умолчанию.

Порядок для хоста с настроенным egress:

sudo mtbuddy update
sudo mtbuddy setup egress '<share-link>' ['<share-link>' …]
sudo systemctl restart mtproto-singbox-egress      # обязательно, см. ниже

Третья строка не опциональна. setup egress пишет новый singbox-egress.json, но дальше делает только systemctl enable --now — а --now это start, no-op для уже запущенного юнита, и sing-box читает конфиг лишь при старте. То есть ровно на том хосте, которому фикс нужен, без явного рестарта продолжит работать старый TUN. Починка ExecStartPre, в отличие от этого, применяется сразу: setup egress делает daemon-reload и рестартит mtproto-proxy.

Проверить:

ip -4 addr show sbx0        # ожидаем 172.19.0.1/32
ip link show sbx0           # ожидаем mtu 1400
resolvectl status sbx0      # ожидаем: ни DNS-сервера, ни домена ~.

Хост, уже крутящийся в 203/EXEC-цикле. mtbuddy update там не доходит до конца: он ставит новые бинарники, затем рестартит прокси, тот всё ещё падает на отсутствующем скрипте, и update откатывает бинарь прокси на бэкап. При этом /usr/local/bin/mtbuddy уже обновлён. Последовательность: mtbuddy update (ждите сообщения об ошибке сервиса) → mtbuddy setup egress '<links>' (пишет ExecStartPre=, прокси стартует) → mtbuddy update ещё раз, чтобы приземлить бинарь прокси.

--force-service на туннельном/egress-хосте использовать не надо. Он заменяет туннельный юнит дефолтным, а тот несёт только CAP_NET_BIND_SERVICE — теряется CAP_NET_ADMIN, без которого не работает SO_MARK, то есть ломается ровно тот egress, который чинили.

Ссылки, если вы их не сохранили, лежат в конфиге:

sudo grep -A2 '^\[upstream.xray\]' /opt/mtproto-proxy/config.toml

Записываются они для оператора, не для автоматики — читателя у этой секции в коде нет, setup egress --from-config не существует.

Повторный запуск setup egress идемпотентен: те же четыре пути, sing-box не перекачивается и не обновляется, блок ухода AmneziaWG-пула на покой пропускается, а в config.toml правятся ровно пять ключей в секциях [upstream] / [upstream.tunnel] / [upstream.xray]. [server] и [access.users] не трогаются — секреты, ad-tag и tls_domain в безопасности. Из заметного: рестарт mtproto-proxy рвёт живые клиентские соединения на мгновение, а рестарт egress-юнита — на секунду-две, пока sbx0 отсутствует.

Пути, которые уже объявляли себя мультидистрибутивными

Задел под #374. Ещё раз: поддержки pacman/dnf в инсталляторе тут нет, гейт на apt-get остался на месте — изменилось только сообщение, оно стало двуязычным и ссылается на issue вместо совета «поставьте apt-get», который на Arch был бы вредным.

  • mtbuddy update хардфейлил на любом не-apt-хосте. Он безусловно запускал apt, чтобы добыть minisign, и сдавался, если тот не появился, — при том что пиненный, SHA-256-проверяемый upstream-бинарь, на который уже откатывается install, лежал через два файла. Больше ничего в обновлении от Debian не зависит. Теперь: apt там, где он есть, потом upstream-фолбэк. Заодно чинится Ubuntu 20.04 focal, где minisign в apt нет: install это умел, update — нет.
  • deploy/bootstrap.sh диспатчил пять пакетных менеджеров, но восстановиться мог только apt. Ветки dnf/yum/apk/pacman звали fail там, где apt-ветка уходила в upstream-бинарь. RHEL и Rocky требуют для minisign EPEL, а включать его на чужой машине — не наше дело; теперь все ветки падают в тот же фолбэк, включая «ни одного известного пакетного менеджера».
  • pacman -Sypacman -S --needed. Синхронизация базы пакетов без обновления — это partial upgrade, и на rolling-дистрибутиве она может поставить пакет, зависимости которого система уже не удовлетворяет, то есть сломать хост, на который мы пришли помогать. Гонять полный -Syu на чужой машине мы тоже не будем — если база устарела и промах случился, его закрывает upstream-бинарь.
  • AmneziaWG получил явную ветку «не поддерживается». awgRepositorySetupForOsRelease был единственным чтением /etc/os-release во всём дереве и двухпозиционным переключателем: debian — или считаем, что Ubuntu. Хост на Arch или Fedora молча уходил в Ubuntu-ветку. Теперь читается и ID_LIKE (Raspbian это ID=raspbian ID_LIKE=debian, Mint и Pop!_OS — ID_LIKE=ubuntu), причём по целым элементам, а не подстрокой. Хост, который не Debian-семейства и вообще без apt, честно объявляется неподдерживаемым: amneziawg-tools есть только в Amnezia Launchpad PPA, на Arch — лишь в AUR (pacman не умеет AUR, makepkg отказывается работать под root), в Fedora/EPEL пакета нет. Проверка к тому же переехала вперёд первого вызова apt-get, который раньше сообщал «Failed to refresh apt package index» и читался как временная проблема с зеркалом. У Debian/Ubuntu поведение не изменилось: при нераспознанном ID классификатор откатывается на наличие apt-get.
  • Убран xxd. Им пользовался дошный deploy/install.sh — hex-кодировал tls_domain для ссылки. Нативный mtbuddy делает это в Zig с #116, и список пакетов оставался последней ссылкой на него во всём дереве. На уже установленных хостах ничего не удаляется.

Проверено

  • zig build test — 354/354 (278 прокси + 76 mtbuddy), включая новые тесты на таблицу уникальных IP, разбор секции и её алиаса, сохранение квоты через SIGHUP, правило «ряд метрик только у пользователей с квотой», MTU/стек//32 и сброс ExecStartPre.
  • Ключевые инварианты проверены мутациями: key-blind release, сравнение только по /64-префиксу, порядок «refs раньше ключа», откат .unsupported, подстрочный матч ID_LIKE, возврат stack:system и /30 — каждая мутация валит хотя бы один тест.
  • zig build e2e — 16/16, включая новый сценарий user_max_ips_distinct_client_networks: два loopback-адреса допускаются, уже допущенный открывает дополнительные сокеты бесплатно, третья сеть до DC не доходит.
  • Installer E2E — все пять ног (debian:11/12, ubuntu:20.04/22.04/24.04) прогоняют полную установку.
  • Кросс-сборки x86_64-linux и aarch64-linux чистые.

Честная оговорка про egress. Живой sing-box-туннель CI не поднимает: installer-E2E доходит только до sing-box version после setup egress --deps-only. Три фикса верифицированы чтением апстрим-исходников — sing-box v1.13.19 и закреплённый в его go.mod коммит sing-tun v0.8.12-0.20260810140523-7c73233bd0fb, — а не прогоном. Версия sing-box нигде не пинится: инсталлятор берёт то, что сейчас в releases/latest, и не обновляет уже установленный. Значение 1400 тоже замерено, а не выведено формулой.

Changelog

  • feat(access): per-user concurrent unique-IP quota ([access.user_max_ips]) (#392) — closes #381
  • fix(egress): repair the sing-box TUN — bulk transfers, host DNS, and a 203/EXEC loop (#395)
  • fix(ctl): make the already-multi-distro paths work, and fail honestly where they don't (#394)

🇬🇧 Release notes (EN)

TL;DR

v1.12.0 adds a per-link unique-IP quota[access.user_max_ips], answering #381. One link may be in use from at most N distinct client networks at the same time.

Plus three bugs in the sing-box egress, found in a live debugging session. Each presents as something other than what it is — the egress reports itself healthy in all three. One blackholed every bulk transfer, one killed DNS resolution across the whole host, one restart-looped the proxy.

And groundwork for #374: the paths that already claimed to be multi-distro but were not. This adds no pacman/dnf support to the installermtbuddy install still requires Debian/Ubuntu, it just says so honestly now.

⚠️ If you run mtbuddy setup egress, an ordinary mtbuddy update is not enough — see "Important for existing hosts".

A per-link unique-IP quota

[access.user_max_ips]   # alias: [access.users_max_unique_ips]
user1 = 4
user2 = 2

Absent or 0 means unlimited; the ceiling is 256. Read at startup, so changing it needs a restart — like its neighbours [access.user_max_conns] and [access.user_expirations]. SIGHUP now says so in its reload line instead of leaving it silent.

[access.user_max_conns] cannot express this: it counts sockets, and one Telegram client opens several by itself. This counts networks:

  • one IPv4 address is one network. The ::ffff:a.b.c.d form a dual-stack listener reports collapses to the same key — otherwise the same phone would occupy two slots depending on which listener accepted it;
  • native IPv6 is counted per /64. Privacy extensions rotate the host bits constantly and SLAAC hands a whole LAN one /64, so keying on the full /128 would burn the quota in minutes.

Counting is strictly concurrent, never historical: a network holds a slot only while it has at least one live connection. Roaming between networks is free — you just cannot hold more than N at once. A connection from an N+1-th network is closed with reason user ip limit; the client is not told why and will keep retrying.

Pick N with room to spare. A single phone can legitimately need more than one slot:

  • on a dual-stack host, its IPv4 address and its IPv6 /64 are separate networks;
  • after a Wi-Fi ↔ mobile handover the old sockets are not closed by the handover itself; 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 starting point. N = 1 will refuse its own owner.

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 being online first. And CGNAT hides thousands of subscribers behind one IPv4 address, so a reseller's whole customer base on one carrier fits in a single slot. Treat it as a guard against casual sharing; a genuinely leaked link still needs a secret rotation.

WEB proxy. Without [web].mask_backend the relay hands the proxy every browser as 127.0.0.1. Those connections are exempt from the quota rather than collapsing into one shared slot — otherwise they would crowd out the user's real devices while limiting nothing. With a PROXY-protocol terminator, peer_addr is the browser's own address and the quota applies as usual.

Observability. Three new series, emitted only for users that configure a quota (the rest are not tracked at all, and a 0 would read as "nobody connected" rather than "unlimited"):

metric what it shows
mtproto_user_unique_ips_active networks currently holding connections
mtproto_user_unique_ips_max the configured quota
mtproto_user_ip_limit_refused_total connections refused because the quota was full

The counter exists precisely because a gauge sitting at its max is the intended steady state for a user at quota — it cannot tell "configured correctly" from "actively turning away a live device". The metrics body buffer goes from 32 to 64 KiB; the new series double the per-user portion of the payload.

A name in [access.user_max_ips] that is missing from [access.users] now warns at startup — silently enforcing nothing is the worst direction for a limit to fail.

If you do not configure the section, nothing about your deployment changes: config parsing, the admission path, the metrics output and memory use are all as before.

Three bugs in the sing-box egress

Bulk transfers went nowhere

The TUN carried no mtu. Without it sing-box ≥1.12 leaves a Linux TUN at 65535 (≤1.11.x left it at 9000) — both far above any usable framing size.

This is not an on-wire size. With a TUN inbound sing-box terminates the TCP flow in its own netstack and re-dials the outbound on an ordinary kernel socket — nothing is encapsulated and no headroom for the outer TLS transport is involved. It is the kernel↔netstack framing size, i.e. the MSS the local kernel negotiates on a DC connection routed via sbx0. Worth stating precisely: the obvious reading sends a debugger at MSS clamping, where there is nothing to find.

At the default, the handshake and the DC connect (small) go through while every transfer is dropped: setup egress prints "up", the proxy then moves 0 B/s, and it reads as a dead upstream.

Now emits "stack":"gvisor","mtu":1400 — the pair measured on a live host as the one that actually passes bulk traffic. It also turns out to be exactly the predicate that enables sing-box's TUN GSO offload (Stack == "gvisor" && tunMTU > 0 && tunMTU < 49152 — it needs both, so the default MTU never gets it), and upstream's own bench puts gvisor at or above the system stack at every MTU.

The TUN address killed DNS across the host

sing-tun's NativeTun.start() calls setSearchDomainForSystemdResolved() unconditionally — the AutoRoute guard beside it is Android-only, so auto_route: false does not stop it. With no explicit DNS servers it synthesises one from the interface address:

if len(t.options.Inet4Address) > 0 && HasNextAddress(t.options.Inet4Address[0], 1) {
    dnsServer = append(dnsServer, t.options.Inet4Address[0].Addr().Next())
}
if len(dnsServer) == 0 { return }
go func() {
    resolvectl domain <tun> "~."
    resolvectl default-route <tun> true
    resolvectl dns <tun> <dnsServer>
}()

Under 172.19.0.1/30 the next address — 172.19.0.2 — falls inside the prefix. ~. is a route-only domain matching every name, so resolved ranks the sbx0 scope above eth0 and sends 100% of queries to 172.19.0.2:53, where nothing listens. Host-wide resolution dies.

Under /32 the next address falls outside the prefix, there is no Inet6Address in the config, dnsServer stays empty — and the function returns before running a single resolvectl command.

Why the address and not something else: EXP_DisableDNSHijack is a library-only field with no JSON tag, so there is no config lever. Reverting afterwards from the route helper would race the detached goroutine. And nothing needed the /30: auto_route is off, so sing-tun installs no routes and the gateway it derives from the address is never applied, while our default route is a dev sbx0 route that takes no nexthop.

The bug was host-dependent — it needs resolvectl on PATH and a running systemd-resolved — which is why it only surfaced in the field.

An orphaned ExecStartPre restart-looped the proxy

setup tunnel writes ExecStartPre=+/usr/local/bin/setup_tunnel.sh into mtproto-proxy.service. Running setup egress afterwards breaks two ways:

  • the AmneziaWG pool-retire path deletes that script → systemd fails the unit with 203/EXEC and restart-loops a proxy that is otherwise healthy;
  • where the script survives (single tunnel, no pool) it re-points table 200 at awg0 on every proxy start and steals the route from sbx0.

The egress.conf drop-in now also writes an empty ExecStartPre= to reset the list. The location is deliberate: a hand-added line is wiped by the next setup egress, while the drop-in survives. sbx0's routing is installed by the sing-box unit's ExecStartPost, and the default (non-tunnel) unit has no ExecStartPre at all, so the reset is a no-op everywhere else.

The reverse direction was already correct: setup tunnel removes egress.conf when it retires the sing-box egress, and deleting the last tunnel from the menu rewrites the unit back to the default.

Important for existing hosts

mtbuddy update fixes none of the three egress bugs. All four files setup egress writes are created once, at setup time, and update does not regenerate them (its only in-place repairs are the v1.11.0 ones for the TCPMSS script and the nfqws unit). After an update, sbx0 still comes up with the /30, stack:system and the default MTU.

The sequence for a host with an egress configured:

sudo mtbuddy update
sudo mtbuddy setup egress '<share-link>' ['<share-link>' …]
sudo systemctl restart mtproto-singbox-egress      # required, see below

That third line is not optional. setup egress writes the new singbox-egress.json but then only runs systemctl enable --now — and --now is start, a no-op on an already-active unit, while sing-box parses its config only at startup. So on precisely the host that needs the fix, the running sing-box keeps the old TUN unless you restart it explicitly. The ExecStartPre fix, by contrast, lands immediately: setup egress does a daemon-reload and restarts mtproto-proxy.

To verify:

ip -4 addr show sbx0        # expect 172.19.0.1/32
ip link show sbx0           # expect mtu 1400
resolvectl status sbx0      # expect no DNS server and no ~. domain

A host already looping with 203/EXEC. mtbuddy update cannot complete there: it installs the new binaries, restarts the proxy, that still fails on the missing script, and update rolls the proxy binary back to its backup. /usr/local/bin/mtbuddy is already the new one by then. So: mtbuddy update (expect a reported service failure) → mtbuddy setup egress '<links>' (writes ExecStartPre=, the proxy starts) → mtbuddy update again to land the proxy binary.

Do not use --force-service on a tunnel or egress host. It replaces the tunnel unit with the default one, which carries only CAP_NET_BIND_SERVICE — dropping the CAP_NET_ADMIN that SO_MARK needs, and breaking the very egress you were repairing.

If you did not keep the links, they are in the config:

sudo grep -A2 '^\[upstream.xray\]' /opt/mtproto-proxy/config.toml

They are persisted for the operator, not for automation — no code reads that section back, and there is no setup egress --from-config.

Re-running setup egress is idempotent: the same four paths, sing-box is not re-downloaded or upgraded, the AmneziaWG pool-retire block is skipped, and config.toml gets exactly five keys rewritten across [upstream] / [upstream.tunnel] / [upstream.xray]. [server] and [access.users] are never touched — secrets, ad tag and tls_domain are safe. Visible cost: the mtproto-proxy restart drops live client connections for a moment, and the egress restart for the second or two sbx0 is gone.

The paths that already claimed to be multi-distro

Groundwork for #374. To be clear again: no pacman/dnf support is added to the installer. The apt-get gate is still there — only its message changed, to a bilingual one that links the issue instead of advising "install apt-get", which on Arch would be actively bad advice.

  • mtbuddy update hard-failed on every non-apt host. It ran apt unconditionally to fetch minisign and gave up if that produced nothing — while the pinned, SHA-256-verified upstream binary that install already falls back to sat two files away. Nothing else about updating is Debian-specific. Now: apt where apt exists, then the upstream fallback. This also fixes Ubuntu 20.04 focal, whose apt has no minisign — install handled that, update did not.
  • deploy/bootstrap.sh dispatched five package managers but only apt could recover. The dnf/yum/apk/pacman branches called fail where the apt branch fell back to the upstream binary. RHEL and Rocky need EPEL for minisign, and enabling it on someone else's box is not ours to do; all branches now fall through to the same fallback, including "no supported package manager found".
  • pacman -Sypacman -S --needed. Syncing the package database without upgrading is a partial upgrade, and on a rolling distro it can install a package whose dependencies the system no longer satisfies — breaking the host we came to help. Running a full -Syu on someone's machine is not ours to do either, so a stale database now lands on the upstream binary instead.
  • AmneziaWG gained an explicit "unsupported" arm. awgRepositorySetupForOsRelease was the only /etc/os-release read in the tree, and a two-way switch: debian, or assume Ubuntu. An Arch or Fedora host silently took the Ubuntu arm. It now reads ID_LIKE too (Raspbian is ID=raspbian ID_LIKE=debian; Mint and Pop!_OS are ID_LIKE=ubuntu), matching whole entries rather than substrings. A host that is neither Debian-family nor has apt at all is reported unsupported: amneziawg-tools ships only in the Amnezia Launchpad PPA, is AUR-only on Arch (pacman cannot install AUR packages and makepkg refuses to run as root), and has no Fedora/EPEL package. The check also moved ahead of the first apt-get call, which previously reported "Failed to refresh apt package index" and read as a transient mirror problem. Debian/Ubuntu behaviour is unchanged: on an unrecognised ID the classifier falls back to apt-get's presence.
  • Dropped xxd. It was used by the pre-mtbuddy deploy/install.sh to hex-encode tls_domain for the share link. The native mtbuddy has done that in Zig since #116, and the package list was its last reference anywhere in the tree. Nothing is removed from already-installed hosts.

Verified

  • zig build test — 354/354 (278 proxy + 76 mtbuddy), including new coverage for the unique-IP table, the section and its alias, the quota surviving a SIGHUP, the "series only for users with a quota" rule, the MTU/stack//32 values and the ExecStartPre reset.
  • The load-bearing invariants are mutation-checked: a key-blind release, a /64-prefix-only comparison, a key-before-refs scan, reverting the .unsupported arm, a substring ID_LIKE match, and restoring stack:system or the /30 — each fails at least one test.
  • zig build e2e — 16/16, including a new user_max_ips_distinct_client_networks scenario: two loopback source addresses are admitted, an already-admitted one opens more sockets for free, and the third network never reaches a DC.
  • Installer E2E — all five legs (debian:11/12, ubuntu:20.04/22.04/24.04) run a full install.
  • Cross-builds clean for x86_64-linux and aarch64-linux.

An honest caveat about the egress. CI never brings a live sing-box tunnel up: the installer E2E only reaches sing-box version after setup egress --deps-only. The three fixes were verified by reading the upstream sources — sing-box v1.13.19 and the sing-tun commit its go.mod pins, v0.8.12-0.20260810140523-7c73233bd0fb — not by running them. The sing-box version is not pinned anywhere: the installer takes whatever releases/latest is at the time, and does not upgrade one that is already installed. The 1400 is likewise measured, not derived.

Changelog

  • feat(access): per-user concurrent unique-IP quota ([access.user_max_ips]) (#392) — closes #381
  • fix(egress): repair the sing-box TUN — bulk transfers, host DNS, and a 203/EXEC loop (#395)
  • fix(ctl): make the already-multi-distro paths work, and fail honestly where they don't (#394)

Don't miss a new mtproto.zig release

NewReleases is sending notifications on new releases.