v1.10.3

sleep3r/mtproto.zigv1.10.3Aug 4, 2026by sleep3r

Upgrading?

AI Summary

Fixes critical bugs where /proc reads returned empty data, OpenSSL 3.5.x incorrectly classified domains, and the TCPMSS clamp was not applied or persisted.

Key Highlights

  • Fixed /proc reading causing hardware AES selection failure.
  • Fixed OpenSSL 3.5.x classification for post-quantum domains.
  • Replaced fire-and-forget TCPMSS clamp with a persistent systemd unit.

New Features

  • Fix for hardware AES selection
  • Fix for OpenSSL PQ domain detection
  • Persistent TCPMSS clamp

Full Release Notes

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

## Коротко

`v1.10.3` — три исправления, найденные при переносе живого прокси на новый сервер. Каждое проверено на реальной системе, а не выведено из чтения кода.

Главное: из-за неверного чтения `/proc` **сборка с аппаратным AES (`x86_64_v3`) не выбиралась никогда и ни у кого** — все установки на x86_64 молча работали на базовой сборке с программным AES. Обновление рекомендуется всем; при `use_middle_proxy = true` эффект на нагрузку CPU от видео особенно заметен.

## Все чтения `/proc` возвращали пустоту

`sys.readFileAllocAbsolute`/`Cwd` использовали `Reader.allocRemaining`, который берёт размер результата из `stat().size`. Все файлы procfs сообщают размер `0`, поэтому чтение возвращало пустой срез. Замерено на Linux:

```
(a) allocRemaining, пустой буфер:  0 байт
(b) allocRemaining, реальный буфер: 0 байт
(c) stat size = 0
(d) цикл readSliceShort:           1087 байт   ← реальное содержимое
```

Следствие: `supportsV3()` никогда не видел строку `flags` и всегда возвращал `false`, поэтому кандидат `x86_64_v3` в `release.zig` даже не пробовался, и **каждая установка на x86_64 тихо откатывалась на базовую сборку** — ту самую, о которой прокси потом предупреждает:

> `AES backend is software-only for this build/target. MiddleProxy video traffic will be CPU-heavy.`

После исправления тот же хост выбирает `mtproto-proxy-linux-x86_64_v3`, и предупреждение исчезает.

Тот же шаблон исправлен в `monitoring.zig` (VmRSS через `/proc/self/status`, секунды CPU через `/proc/self/stat` и лимиты памяти cgroup — всё это молча возвращало пустоту, то есть метрики памяти и CPU не работали) и в резервном пути `/proc/meminfo` в `main.zig`, который был замаскирован тем, что обычно первым отвечает `sysinfo(2)`.

Чтение теперь падает с `StreamTooLong`, а не обрезает данные, — иначе частично прочитанный `config.toml` разобрался бы как корректный, но более короткий документ. Из-за этого лимит на `/proc/cpuinfo` впервые стал значимым и поднят с 256 KiB до 4 MiB: cpuinfo повторяет ~1.1 KiB на каждый логический процессор, и старый лимит отвечал бы «нет v3» уже после ~235 потоков — ровно на тех машинах, где аппаратный AES нужнее всего.

## Проверка fronting-домена неверно читала вывод OpenSSL 3.5.x

`classifyOpenSslOutput` искал только строку `Server Temp Key`. OpenSSL 3.5.5 для гибрида ML-KEM её не печатает вовсе, выводя вместо неё `Negotiated TLS1.3 group: <группа>` — поэтому по-настоящему PQ-совместимый домен помечался как непригодный (HRR).

Теперь разбирается **значение** строки, и это принципиально: сервер, у которого нет общей группы с нашим предложением, всё равно печатает эту строку — со значением `<NULL>` (проверено на `wb.ru` и `mail.ru`, предпочитающих secp521r1; рукопожатие завершается с `Cipher is (NONE)`). Реакция на одно лишь наличие строки пометила бы их как «однораундный x25519» — ошибка в противоположную сторону.

Проверено вживую на OpenSSL 3.5.5:

```
ozon.ru → negotiates X25519MLKEM768 (post-quantum) — хороший fronting-домен
wb.ru   → HelloRetryRequest / не-x25519 группа — несовпадение
```

## TCPMSS сообщал об успехе, которого не было, и пропадал после перезагрузки ([#383](https://github.com/sleep3r/mtproto.zig/discussions/383))

Сообщено в #383 на Debian 12/13 и воспроизведено здесь на чистом хосте: после того как установщик напечатал `TCPMSS clamping applied`, правила в `mangle/OUTPUT` не оказалось, а `/etc/iptables/rules.v4` остался нулевого размера.

Причины: правило применялось по принципу «выстрелил и забыл» (`_ = sys.exec(...) catch {}`) с безусловным сообщением об успехе; наличие правила не проверялось (в отличие от nfqws); и передавалось голое `"iptables"`, тогда как `nfqws.zig` намеренно разрешает абсолютные пути, потому что в минимальном root-окружении `/usr/sbin` может отсутствовать в `PATH`.

Заменено на генерируемый скрипт плюс oneshot-юнит `mtproto-tcpmss.service` по образцу уже существующего `mtproto-syn-limit.service`:

- **переустанавливает правило при каждой загрузке** — раньше его ничто не восстанавливало, поскольку `iptables-persistent` мы намеренно не ставим;
- **проверяет** результат через `iptables -C` и показывает настоящую ошибку iptables;
- итоговая сводка отражает реальное состояние хоста, как это уже сделано для masking и nfqws;
- выключение клампа теперь корректно демонтирует юнит, а не оставляет включённый юнит, который каждую загрузку возвращает правило, объявленное в сводке отключённым.

### `set -e` был критичен и отсутствовал — здесь и в `synlimit.zig`

С одним лишь `set -u` завершающий `exit 0` затирал статус всех предыдущих команд, поэтому oneshot **всегда** завершался успешно, и systemd показывал `active (exited)` поверх набора правил, который правило так и не получил. То есть существующий комментарий в `synlimit.zig` — *«a failure here fails the unit so the operator notices»* — тоже никогда не соответствовал действительности. Оба скрипта переведены на `set -eu`, и оба теперь передают `-w`, чтобы конкуренция за xtables-lock с ufw/docker при загрузке не роняла юнит без причины, раз ненулевой статус стал фатальным.

Проверено с подставным iptables под `sh`, `dash` и `bash`:

| случай | было | стало |
|---|---|---|
| правило применилось | 0 | 0 |
| правило отвергнуто | **0** | 1 |
| xtables lock (код 4) | **0** | 4 |
| режим `flush` | 0 | 0 |

И на живом хосте: намеренно сломанный apply теперь оставляет юнит в состоянии `failed`, а не `active`.

## Проверено

- `zig build test` — 274/274; новые тесты покрывают вердикт для группы `<NULL>`, наличие `set -eu` / `-w` / обратного чтения `-C` в генерируемом скрипте и семантику юнита при старте и остановке.
- Кросс-сборки `x86_64-linux` и `aarch64-linux` чистые.
- Все три исправления проверены на живом развёртывании.

## Changelog

- fix(ctl): read procfs correctly, make the TCPMSS clamp real and persistent ([#386](https://github.com/sleep3r/mtproto.zig/pull/386))

</details>

---

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

## TL;DR

`v1.10.3` carries three fixes found while migrating a live proxy to a new host. Each was verified against a real system rather than inferred from reading the code.

The headline: because of an incorrect `/proc` read, **the hardware-AES (`x86_64_v3`) build was never selected on any machine** — every x86_64 install silently ran the baseline build with software AES. Upgrading is recommended for everyone; with `use_middle_proxy = true` the effect on video CPU load is especially noticeable.

## Every `/proc` read came back empty

`sys.readFileAllocAbsolute`/`Cwd` used `Reader.allocRemaining`, which sizes its result from `stat().size`. Every procfs file reports size `0`, so the read returned an empty slice. Measured on Linux:

```
(a) allocRemaining, empty buffer: 0 bytes
(b) allocRemaining, real buffer:  0 bytes
(c) stat size = 0
(d) readSliceShort loop:          1087 bytes   <- the real content
```

So `supportsV3()` never saw a `flags` line and always answered `false`. `release.zig` therefore never even tried its `x86_64_v3` candidate, and **every x86_64 install silently fell back to the baseline build** — the one the proxy then warns about:

> `AES backend is software-only for this build/target. MiddleProxy video traffic will be CPU-heavy.`

After the fix the same host resolves `mtproto-proxy-linux-x86_64_v3` and the warning is gone.

The identical pattern is fixed in `monitoring.zig` (VmRSS via `/proc/self/status`, CPU seconds via `/proc/self/stat`, and the cgroup memory limits — all silently reporting nothing, i.e. memory and CPU metrics were dead) and in `main.zig`'s `/proc/meminfo` fallback, which was masked only because `sysinfo(2)` normally answers first.

The read now fails with `StreamTooLong` rather than truncating, so a partially-read `config.toml` can never be parsed as a valid but shorter document. That makes the `/proc/cpuinfo` limit load-bearing for the first time, so it moves from 256 KiB to 4 MiB: cpuinfo repeats ~1.1 KiB per logical CPU, and the old cap would have answered "no v3" past ~235 threads — exactly the hosts where hardware AES matters most.

## Fronting-domain probe misread OpenSSL 3.5.x

`classifyOpenSslOutput` only matched `Server Temp Key`. OpenSSL 3.5.5 prints no such line for an ML-KEM hybrid, emitting `Negotiated TLS1.3 group: <group>` instead — so a genuinely PQ-capable domain was reported as an unusable HRR one.

It now parses the line's **value**, which matters: a server sharing no group with our offer still prints the line, completed with `<NULL>` (verified against `wb.ru` and `mail.ru`, which prefer secp521r1, and whose handshake ends in `Cipher is (NONE)`). Reacting to the line's mere presence would have mislabelled those as single-round x25519 — the opposite error.

Confirmed live on OpenSSL 3.5.5:

```
ozon.ru -> negotiates X25519MLKEM768 (post-quantum) - a good fronting target
wb.ru   -> HelloRetryRequest / non-x25519 group - mismatch
```

## TCPMSS clamp reported success it never achieved, and vanished on reboot ([#383](https://github.com/sleep3r/mtproto.zig/discussions/383))

Reported in #383 on Debian 12/13 and reproduced here on a fresh host: after the installer printed `TCPMSS clamping applied`, no rule existed in `mangle/OUTPUT`, and `/etc/iptables/rules.v4` was left 0 bytes.

Causes: the clamp was applied fire-and-forget (`_ = sys.exec(...) catch {}`) and then unconditionally reported success; it never verified the rule landed (unlike nfqws); and it passed a bare `"iptables"` while `nfqws.zig` deliberately resolves absolute paths, because a minimal root environment can spawn without `/usr/sbin` on `PATH`.

Replaced with a rendered script plus an `mtproto-tcpmss.service` oneshot, mirroring the existing `mtproto-syn-limit.service` pattern:

- **re-applies at every boot** — nothing else restored it, since we deliberately don't install `iptables-persistent`
- **verifies** via `iptables -C` and surfaces the real iptables error
- the final summary reports what the host actually has, matching the existing masking/nfqws discipline
- turning the clamp off now tears the unit down, instead of leaving an enabled unit re-applying a rule the summary calls disabled

### `set -e` was load-bearing and missing — here and in `synlimit.zig`

With `set -u` alone, the trailing `exit 0` overwrote every earlier status, so the oneshot **always** succeeded and systemd reported `active (exited)` over a ruleset that never received the rule. That means `synlimit.zig`'s existing comment — *"a failure here fails the unit so the operator notices"* — was never true either. Both scripts now use `set -eu`, and both pass `-w` so a boot-time xtables-lock collision with ufw/docker doesn't fail a unit spuriously now that a non-zero status is fatal.

Verified with a stub iptables under `sh`, `dash` and `bash`:

| case | before | after |
|---|---|---|
| append succeeds | 0 | 0 |
| append rejected | **0** | 1 |
| xtables lock (exit 4) | **0** | 4 |
| `flush` mode | 0 | 0 |

And on the live host, a deliberately broken apply now leaves the unit `failed` rather than `active`.

## Verified

- `zig build test` — 274/274; new tests cover the `<NULL>` group verdict, the rendered script's `set -eu` / `-w` / `-C` read-back, and the unit's boot and stop semantics.
- `x86_64-linux` and `aarch64-linux` cross-builds clean.
- All three fixes verified on a live deployment.

## Changelog

- fix(ctl): read procfs correctly, make the TCPMSS clamp real and persistent ([#386](https://github.com/sleep3r/mtproto.zig/pull/386))

</details>

---

**Full Changelog**: https://github.com/sleep3r/mtproto.zig/compare/v1.10.2...v1.10.3

Related Repositories