v1.12.0

sleep3r/mtproto.zigv1.12.0Aug 24, 2026by sleep3r

Upgrading?

AI Summary

Introduces a per-link unique IP quota system to limit concurrent connections from distinct networks and fixes three critical bugs in the sing-box egress implementation (MTU, DNS resolution, and a systemd loop).

Key Highlights

  • Adds [access.user_max_ips] to limit the number of unique client networks per link.
  • Fixes sing-box TUN bug causing bulk transfer losses by setting correct MTU (1400) and stack type.
  • Fixes DNS resolution failure on hosts by correcting the TUN interface address configuration (/32 instead of /30).
  • Fixes a systemd restart loop caused by an orphaned ExecStartPre script.
  • Improves multi-distro support in mtbuddy update and bootstrap.sh to handle non-apt systems gracefully.

New Features

  • Per-user unique IP quota (configurable via TOML).
  • Sing-box TUN MTU fix enabling GSO offload.
  • Sing-box TUN DNS fix preventing host-wide resolution failure.
  • Sing-box systemd ExecStartPre cleanup.
  • Multi-distro update support with upstream binary fallback.

Full Release Notes

<details open>
<summary>🇷🇺 Что нового (RU)</summary>

## Коротко

`v1.12.0` добавляет **лимит уникальных IP на ссылку** — `[access.user_max_ips]`, ответ на [#381](https://github.com/sleep3r/mtproto.zig/issues/381). Одна ссылка может работать одновременно максимум из N разных клиентских сетей.

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

И задел под [#374](https://github.com/sleep3r/mtproto.zig/issues/374): пути, которые *уже* объявляли себя мультидистрибутивными, но не работали. **Поддержки pacman/dnf в инсталляторе это не добавляет** — `mtbuddy install` по-прежнему требует Debian/Ubuntu, просто теперь говорит об этом честно.

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

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

```toml
[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-серверы явно не заданы, он синтезирует один из адреса интерфейса:

```go
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:

```bash
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`.

Проверить:

```bash
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, который чинили.

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

```bash
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](https://github.com/sleep3r/mtproto.zig/issues/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 -Sy` → `pacman -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](https://github.com/sleep3r/mtproto.zig/pull/392)) — closes [#381](https://github.com/sleep3r/mtproto.zig/issues/381)
- fix(egress): repair the sing-box TUN — bulk transfers, host DNS, and a 203/EXEC loop ([#395](https://github.com/sleep3r/mtproto.zig/pull/395))
- fix(ctl): make the already-multi-distro paths work, and fail honestly where they don't ([#394](https://github.com/sleep3r/mtproto.zig/pull/394))

</details>

---

<details>
<summary>🇬🇧 Release notes (EN)</summary>

## TL;DR

`v1.12.0` adds a **per-link unique-IP quota** — `[access.user_max_ips]`, answering [#381](https://github.com/sleep3r/mtproto.zig/issues/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](https://github.com/sleep3r/mtproto.zig/issues/374): the paths that *already* claimed to be multi-distro but were not. **This adds no pacman/dnf support to the installer** — `mtbuddy 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

```toml
[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:

```go
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:

```bash
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:

```bash
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:

```bash
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](https://github.com/sleep3r/mtproto.zig/issues/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 -Sy` → `pacman -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](https://github.com/sleep3r/mtproto.zig/pull/392)) — closes [#381](https://github.com/sleep3r/mtproto.zig/issues/381)
- fix(egress): repair the sing-box TUN — bulk transfers, host DNS, and a 203/EXEC loop ([#395](https://github.com/sleep3r/mtproto.zig/pull/395))
- fix(ctl): make the already-multi-distro paths work, and fail honestly where they don't ([#394](https://github.com/sleep3r/mtproto.zig/pull/394))

</details>

Related Repositories