# Изменения HY2XS Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Проект использует [семантическое версионирование](https://semver.org/lang/ru/). Версия относится к самому HY2XS, а не к Hysteria: версия Hysteria выбирается на этапе сборки пакета и фиксируется в его metadata. ## [Unreleased] ### Документация — итоговая приёмка v1.0.0 - Зафиксирован финальный production-прогон `1.0.0-rc5`: build, повторная проверка опубликованного артефакта, чистая установка на Debian 13, reboot, Admin/UI, управление пирами, diagnostics и внешний E2E официальным клиентом Hysteria 2.12.2 дали `PASS`. - Отдельно записан downstream gateway/TUN-прогон через `vpn-egressctl 0.2.0` и sing-box 1.14.0, включая реального клиента за `br_vpn`, idempotent sync и fail-closed / anti-leak проверку. - Release identity привязана к source commit `b7e478f0143846633d1261ca0bcd220736d28f44`, Build ID `rc5-final-fi-20260908T003829Z` и SHA-256 артефакта `d35cf469d8e1e3b5c1e79cb94329c1b19d1afbd7ade771498d2fb8caf5cd746a`. Stable `v1.0.0` опубликован из этих же байтов без пересборки и по решению release operator остаётся привязан к тегу `1.0.0-rc5`; docs-only коммиты не меняют идентичность проверенного кандидата. - Полный протокол опубликован в [итоговом отчёте о приёмке RC5](docs/acceptance/2026-09-15-v1.0.0-rc5-final-acceptance.md). - Отдельная [фиксация stable promotion](docs/acceptance/2026-09-15-v1.0.0-stable-promotion.md) записывает фактическую связь release/tag и basename-only checksum-sidecar размером 93 байта. Runtime-разбор RC4 закрыл три дефекта панели и один связанный риск сопровождения. Набор знаков имени пира больше не хранится как сырой `@` внутри message format: общий frontend-контракт передаёт его в RU/EN-подсказку через named interpolation и сверяется с Go-константами. Builder после frozen install компилирует реальным `vue-i18n` каждую строку обоих словарей, перехватывая как исключения, так и compiler diagnostics в `console.error`; удалить этот gate и при этом получить `tests_gate=true` нельзя. Сам `vue-i18n` переведён с неподдерживаемой ветки 9.14.5 на поддерживаемую 11.4.10. Попутный аудит package manager устранил ложную декларацию security overrides: pnpm предупреждал, что больше не читает настройки из поля `pnpm` в `package.json`. Правила перенесены в `apps/frontend/pnpm-workspace.yaml`, то есть теперь не только сохранены в старом lock-файле, но и действительно участвуют в следующем разрешении графа. Dashboard использует `value` у `el-radio-button` вместо deprecated-семантики «`label` как значение», а общий frontend-контракт запрещает вернуть старый API в любом Vue-компоненте. ENV-redaction теперь имеет одно точное исключение для диагностического boolean `HY2XS_FORCE_PASSWORD_CHANGE`: корректное значение `true`/`false` видно в bundle, но другое значение либо совпадение по префиксу/суффиксу исключения не даёт, и пароли, токены, секреты Hysteria и credentials продолжают заменяться на ``. По результатам runtime-проверки RC3 login smoke приведён к реальному HTTP контуру панели: positive и negative пробы используют один helper с wire-полем `pass`, явным `HY2XS-Installer/1.0` User-Agent и строгим разбором JSON envelope. Тесты теперь проходят через scanner middleware и отдельно доказывают отказ стандартного curl UA, успешный bootstrap-вход и `invalid_credentials` для случайного пароля. Rollback cleanup проверяет `ActiveState` после `reset-failed`: уже выгруженный юнит больше не создаёт ложное предупреждение о ручном восстановлении, а недоступный systemd и сохранившийся `failed` остаются ошибкой. Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет `hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог `/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно, symlink и ослабленные права дают fail-closed отказ, а временный staging после упаковки удаляется. Чтение metadata теперь использует fallback только при `ENOENT`; ошибки прав, ввода-вывода и неверного типа больше не маскируются. Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile до разбора ключей. HTTP API до `encoding/json` отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape, поэтому вход или импорт больше не могут молча превратиться в другое значение через U+FFFD. Diagnostics сначала санитизирует данные в памяти и никогда не кладёт сырые env/YAML/journal во временный каталог. U+FEFF во всех слоях отнесён к документированному домену systemd, а управляющие `Cc` — к политике HY2XS. Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику «только чистая установка» настоящим системным инвариантом, а не строчкой в документации. Второй проход закрывает то, что осталось: два контракта приёмки, гарантированно ронявшие сборку на корректном коде; жизненный цикл планировщика; мёртвое состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст графа зависимостей. Третий проход — операции, которые делают не то, что обещает их имя: отзыв доступа, не переживающий перезапуск; резервная копия, молча получающаяся неполной; диагностика, обрывающая соединения; аварийный выход сборки, которым невозможно воспользоваться. Четвёртый проход — failure path и релизные гейты: восстановление после неудачной установки, которое умело отменить само себя, два гейта сборки, проверявшие не то, что обещали, и два свойства, которые были описаны, но не обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`. Пятый проход — нижний слой того же механизма. Верхнеуровневый откат стал надёжным раньше, чем его storage/firewall substrate: откат гарантированно запускался, но отдельные его шаги могли молча не выполнить восстановление, отчитаться успехом и уничтожить резервную копию. Шестой проход — управление самой транзакцией, а не копированием файлов. Предыдущие проходы сделали надёжными шаги операции; здесь закрываются два допущения, на которых держалась операция целиком: что снятие защиты от отката действительно произошло и что операция на сервере ровно одна. Седьмой проход — приведение барьера покоя к реальной семантике systemd 257, который стоит на Debian 13. Механизм, обязанный **доказать** отсутствие асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. Восьмой проход — сами релизные гейты. Проверка, которая инвертирует свой результат на достаточно большом входе, опаснее отсутствующей: отсутствующая ничего не обещает. Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и сервер действительно умел его сгенерировать — до этой генерации не доходило управление. Контракт `currentColor` был объявлен в двух местах — и не действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла, войдите заново» существовала — и была недостижима сразу по двум причинам. Десятый проход — разбор кода после девятого, на коммите `c0a43ae9`, со сверкой Hysteria-интеграции с официальной документацией Hysteria 2. Тема прохода: операции, у которых есть только одна из двух необходимых половин. Отключение пира записывало запрет и не разрывало его сессию. Ограничение устройств проверялось, пока отвечал Traffic Stats API, и молча снималось, когда он не отвечал. Журнал собирал структурные поля и записывал пустое сообщение. Разбор задокументирован в [docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md). Одиннадцатый проход — второй разбор того же слоя, уже по состоянию после десятого. Тема: границы между частями access-control. Десятый проход починил одну операцию отзыва доступа и оставил остальные — удаление, импорт, смену секрета, урезание квоты и срока, снижение лимита устройств — в прежнем состоянии; правило доступа при этом продолжало существовать в двух экземплярах, написанных разными SQL-условиями, которые расходились ровно на границах. Проведены три границы: `состояние пира → решение о доступе`, `сохранённое изменение → живая сессия`, `планировщик → полностью принадлежащая ему работа`. Двенадцатый проход — разбор состояния после одиннадцатого, снова со сверкой с официальной документацией Hysteria 2. Тема: **вторая попытка**. Одиннадцатый проход сделал правильным порядок «сначала запись, потом разрыв» и правильно запретил откат при неудаче разрыва — но не дал системе способа прийти к согласованному состоянию потом. Два состояния оставались навсегда: сессия пира, которого импорт переподписал или удаление убрало, и превышение лимита устройств после неудавшегося снижения. Вместе с ними закрыт второй TOCTOU в лимите устройств — переупорядочивание снимков `/online`, которое учёт разрешений сам по себе не ловил, а детектор гонок не мог показать в принципе. Разбор задокументирован в [docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md). Тринадцатый проход — граница между HY2XS и Hysteria, со сверкой по исходникам тега `app/v2.12.2`, а не только по документации. Предыдущие два прохода привели в порядок внутреннюю логику отзыва доступа; здесь закрываются места, где эта логика соприкасается с чужим компонентом и с оператором: идентичность сессий, которая не менялась вместе с учётными данными и потому лишала цикл учёта единственного признака отзыва; адрес Traffic Stats API, у которого было два несовместимых контракта в одном продукте; формат журнала Hysteria, который разбирался неверно на каждой строке; и панель, показывавшая как факт то, чего никто не проверял. Разбор задокументирован в [docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md). Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал, `/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый запрос, потому что валидатор паниковал на теге правила, пережившего переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер, проверяющий каждый тег валидации на существование, и smoke, выполняющий настоящий вход. Прогон задокументирован в [docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md). Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход свёл правило о пароле в одно место и объявил его границей в 64 символа, обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII: `golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене пароля, а после установки — отсутствием администратора вовсе. Хуже самого дефекта было то, что новый тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword` и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения; панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер считает code points; и сам транспорт — `hy2xs.env` — не был форматом, переживающим произвольное значение, хотя его читает не только оркестратор, но и systemd. Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой глубже: **контракт, объявленный шире, чем его принимает чужая сторона**. Пятнадцатый проход сделал транспорт lossless для значений, которые systemd принимает, но не спросил, какие значения он принимает вообще. Вместе с этим закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл научился нести произвольные значения, и порядок, при котором детерминированная ошибка конфигурации обнаруживалась уже после мутаций хоста. ### Исправлено — вход в панель - **`POST /api/auth/login` паниковал до проверки учётных данных.** На `LoginDto.Username` стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой установки была недоступна целиком. - **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору отдельно — закрыт класс, а не найденный экземпляр. - **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают истинны при полностью недоступной панели. Теперь smoke выполняет настоящий `POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и на отказ. Отрицательная проба выполняется в любом режиме операции. - **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER` по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и установка с ним завершалась успешно, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения, умолчание сведено к `hy2xsadmin` во всех трёх местах. - **Панель была строже сервера и запирала оператора после смены пароля.** Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь объявлен один раз в `apps/credential/admin.go`, а копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. - **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. - **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов, а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед переключателем видимости. Индикация перенесена на элемент, который оператор и видит полем. - **Требование не называлось.** «Неверный формат логина» и «Некорректное значение» не давали оператору ни одного способа узнать, что от него хотят: набор символов логина приходит из `hy2xs.env` и в панели нигде не показан. Фразы форм и серверная причина `credential_format` теперь перечисляют границы и набор. - **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка ставится в `service.Login` сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. ### Исправлено — домен значений файла окружения - **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как принять пару, systemd прогоняет ключ и значение через `utf8_is_valid` (`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает `-EINVAL` — то есть НЕзагруженный `EnvironmentFile=` и юнит, который не стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`) отвергает суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в `hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради уничтожения которого контракт и существует, только слоем ниже. Введён `IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий множество systemd точно — не шире и не уже. - **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и оркестратор отвергают такие значения явно. - **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри двойных кавычек перевод строки накапливается как обычный байт и переживает round-trip. Именно эта подмена и позволила проверке не знать про noncharacters. Домен транспорта и политика продукта теперь разделены и проверяются отдельными тестами. - **Политика была шире кода ровно на C1.** Документация обещала «без управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`. Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd такое значение принимает (комментарий `/* BOM */` в его исходнике относится к `U+xFFFE` и является неточностью самого systemd). - **Порт автомата расходился с upstream на `\`.** В состоянии `DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт съедал и `\`, и `\`, то есть прочитал бы такое значение иначе, чем тот, для кого файл в конечном счёте написан. - **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда как systemd принимает накопленное. Оба расхождения fail-closed и теперь названы оба. ### Исправлено — рецепт восстановления выполнял env-файл как код - **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль администратора».** Строка стала опасной ровно тогда, когда файл научился нести произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"` — буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.` обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку команд — от root, прямо в рецепте восстановления доступа. Причём соседний раздел той же страницы уже правильно запрещал `source`/`eval` для `bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал. Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий возврат `source`/`.`/`eval` над этими файлами в командах документации и в скриптах. ### Исправлено — отказ приходил после мутаций хоста - **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и раскладки файловой системы, — а read-only `preflight-install` говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная ошибка конфигурации роняла операцию, оставив за собой изменённый хост, что прямо противоречит контракту PHASE 0. `validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же. ### Исправлено — контракт пароля администратора - **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64 СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64 символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76. `bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`) отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из хеширования: на смене пароля — системной ошибкой, при установке — падением старта админки, то есть сервером без администратора после `INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes = 72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x `😀` = 72 байта) закреплены тестами во всех четырёх слоях. - **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв — ровно того значения, на котором продукт ломался. Тест инвертирован. - **Пароль триммился в двух местах вопреки собственному контракту.** Контракт объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни при смене. При этом `util.HashPassword` вёл собственную проверку `len(strings.TrimSpace(password)) < 6`, а bootstrap читал `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия: пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в `hy2xs.env`. Оба тримминга убраны. - **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword` существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`, `dao` и `service`, — теперь у него физически не может появиться второго экземпляра. - **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.** `min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на логине; у пароля она вдобавок не могла выразить границу в байтах, потому что правила длины в байтах у `go-playground/validator` нет. Введено одно правило `adminPassword` и код причины `admin_password_format`, называющий обе границы. - **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с `String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил минимум формы и получал отказ сервера, который панель не могла объяснить: сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты. - **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните `hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата `parse_env_file_internal` из systemd и обратный ему кодировщик; тем же кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без кавычек, поэтому релизные гейты и инструкции оператора не меняются. - **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт — перевод строки является границей записи, — а ввести такой пароль в однострочное поле формы входа всё равно нельзя: он умел только запереть оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль доезжает до bcrypt побайтово тем же, каким его задал оператор. - **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}` с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в момент пробы, а результат сверяется с конвертом: `code: 50000`, причина `invalid_credentials`, отсутствие `accessToken`. - **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d= -f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы: проба взяла бы не тот пароль и объявила бы рабочую установку сломанной. Bootstrap-секрет читается парсером собственного формата, минуя shell. ### Исправлено — правило доступа - **Исчерпанная квота не отключала пира никогда.** Правило доступа существовало в двух экземплярах: SQL-условием внутри `Hysteria2Auth` и другим SQL-условием внутри cron. Второе не было отрицанием первого, и расхождение приходилось на границы — `quota = 0`, `usage = quota`, `now = expiresAt`, `now = bannedUntil`: авторизация отказывала, cron сессию не рвал. Условие cron требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора. Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась никогда. Политика вынесена в одну функцию `peerAccessDenied`; авторизация ищет пира только по `secret_digest`, cron применяет ту же функцию. `quota = -1` — единственный способ снять ограничение, `quota = 0` означает ноль байтов, `usage = quota` означает исчерпанный лимит, `bannedUntil = now` означает закончившуюся блокировку. Строка без решающего поля трактуется как повреждённая и ведёт к отказу. ### Исправлено — операции, оставляющие живую сессию - **Удаление пира не отзывало доступ и теряло `authId`.** `DeletePeer` состоял из одного `dao.DeletePeer`: строка исчезала, живая QUIC-сессия оставалась, а вместе со строкой исчезал `auth_id` — единственное, чем эту сессию можно было бы завершить. Состояние становилось невосстановимым. Теперь: прочитать пира и запомнить `authId` → записать `disabled=1` → `/kick` → удалить строку. При неудаче разрыва строка остаётся отключённой, и оператор повторяет удаление. - **Разрыв выполнялся только при `disabled=1`.** Мимо проходили смена секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и снижение лимита устройств — каждая из них закрывает доступ, но сессию не трогала. Правило асимметрично: ограничение применяется немедленно, послабление — нет. При любом сочетании изменений уходит ровно один `/kick`. - **Импорт не завершал сессии переписанных пиров.** Импорт переписывает `auth_id`, секрет, квоту, срок и `disabled` целиком. Старые `authId` собираются внутри транзакции — после commit их в базе уже нет, — а разрыв идёт после commit: до него клиент успел бы переподключиться к ещё не изменённому пиру. - **Единственный вход к `/kick`.** Все операции идут через один `reconcileLiveSessions`, а он — через `disconnectAuthIDs`, который принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не обращается к базе вовсе. Пока обращений к `/kick` было два, они расходились: у cron не было ни дедупликации, ни разбиения, зато был POST с пустым массивом каждые 30 секунд. - **Формулировка частичного результата больше не называет операцию.** Через `peer_disconnect_failed` отчитываются восемь операций; прежнее «новые подключения пира запрещены» было верно ровно для отключения пира, а для удалённого — бессмысленно. Контроллеры удаления и импорта переведены на `failService`, панель разбирает исход импорта и обновляет список при любом результате. ### Исправлено — цикл учёта - **Джоба убегала из жизненного цикла планировщика.** `CronHandleAccount` запускала горутину, которая запускала ещё две. Для планировщика джоба заканчивалась почти мгновенно, поэтому `StopCron()` не ждал настоящей работы: `releaseResource()` закрывал SQLite, а горутины продолжали в неё писать. Параллельность обеих половин означала ещё и то, что принудительное отключение читало счётчики до записи снятой дельты. Теперь джоба синхронна, под одним мьютексом на весь цикл, и порядок строгий: сбор трафика, затем enforcement. - **Три nil-разыменования роняли процесс целиком.** `*trafficSecretConfig.Value`, `*item.AuthId` в принудительном отключении и `*item.Id` в сбросе трафика — все внутри горутин, где их некому перехватить, то есть каждое означало падение сервиса вместе с обработчиком machine-auth. - **Гейт `Hysteria2IsRunning` удалён из cron.** `util.Exec` не отличает «служба неактивна» от «спросить не удалось», поэтому сломанный `systemctl` при живой Hysteria молча отключал и учёт трафика, и принудительное отключение — без единой строки в журнале. - **Потеря дельты трафика больше не молчит.** `GET /traffic?clear=1` деструктивен: счётчики Hysteria обнуляются сразу после отправки ответа. Прежний код на отказе записи делал `continue`, и дельта исчезала, не оставив следа в исходе джобы. Полное решение требует смены модели учёта (недеструктивное чтение плюс долговременные checkpoint'ы) и в `1.0.0` намеренно не вводится: квота — операционный предел доступа, а не учёт с финансово значимым каждым байтом. ### Исправлено — лимит устройств под нагрузкой - **Параллельные подключения превышали `maxDevices`.** Между чтением `/online` и ответом «allow» место ничем не удерживалось: при `online = max-1` два одновременных запроса получали разрешение оба. Мьютекс вокруг `/online` этого не чинит — ответив «allow», админка не создаёт подключение, и следующий запрос продолжает видеть прежнее число. Появился process-local учёт выданных, но ещё не проявившихся разрешений: решение принимается по сумме «подключено плюс зарезервировано», рост `online` снимает соответствующее число резерваций, протухшие снимаются по внутреннему TTL. Ни Redis, ни таблиц в базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере. ### Исправлено — сходимость отзыва доступа (двенадцатый проход) - **Устаревший снимок `/online` возвращал уже занятое место.** Учёт выданных разрешений закрыл сравнение двух одинаковых снимков, но сетевой запрос по-прежнему выполнялся вне блокировки, поэтому снимки приходили в резервацию в произвольном порядке. Более старый обгонял более новый и откатывал `lastOnline` назад: `A` получил разрешение при `online = 0`; `C` обработал `online = 1` первым и признал разрешение `A` проявившимся; пришедший следом `B` со своим устаревшим `0` увидел место снова свободным. При `maxDevices = 1` подключений становилось два. Детектор гонок здесь молчит принципиально — вся работа с памятью защищена мьютексом, гонка логическая. Последовательность «прочитать `/online` → занять место» выполняется под замком **по `authId`**, а не одним на процесс: внутри неё идёт сетевой запрос, и общий замок выстроил бы подключения всех пиров в очередь за одним HTTP-обменом. Карта замков не растёт — запись живёт ровно столько, сколько есть желающие её взять. - **Живая сессия без строки в базе не завершалась никогда.** Цикл учёта читал `dao.ListPeer("auth_id in ?")` и обходил найденные строки, поэтому `authId`, которому в базе ничего не соответствует, молча выпадал. А именно он и остаётся единственным следом сессии после неудавшегося второго шага: импорт заменил `auth_id`, удаление убрало строку. Повторить операцию в этом состоянии невозможно — повтор того же импорта читает из базы уже новое значение и рвёт его, — а восстановить состояние переподключением нельзя: авторизация нового значения не знает. Сессия жила неограниченно долго. - **Снижение `maxDevices` после неудавшегося разрыва не имело второй попытки.** Условие сравнивало `*peerDto.MaxDevices < *before.MaxDevices`, а форма при правке отправляет все поля, поэтому повторное сохранение давало `1 < 1` и разрыва не делало. Лимит устройств в политику доступа не входит и входить не должен — это свойство сессий, а не пира, — поэтому механизма схождения у него не было вовсе, в отличие от `disabled`, квоты, срока и блокировки. - **Цикл учёта стал сверкой живых сессий.** Обход идёт по каждому `authId` из `/online`: нет строки в базе → разрыв; `peerAccessDenied` → разрыв; непригодный `maxDevices` → разрыв; устройств больше разрешённого → разрыв. Отказ базы при этом не рвёт ничего: «пира нет» и «прочитать не удалось» — разные ответы, и трактовка второго как первого отключила бы всех подключённых пиров сразу при недоступной SQLite. Таблицы отложенных операций, очереди retry и хранимого «списка того, что не удалось разорвать» не появилось: список живых сессий уже есть, и это `/online`. Число устройств сверено с официальным контрактом Traffic Stats API — `/online` возвращает количество экземпляров клиента Hysteria, а не число proxy-потоков. - **Go 1.26.7 → 1.26.8.** Patch-релиз от 2026-09-01 (fixes в cgo, компиляторе, runtime, `debug/elf` и `os`). Stdlib целиком попадает в production-бинарь, поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды сборки. Обновлены `GO_VERSION` с контрольной суммой и `toolchain` в `apps/go.mod`: расхождение между ними роняет сборку на `verify_go_toolchain_contract`. Major не менялся — линия 1.26 поддерживается. - **Учёт разрешений больше не растёт бесконечно.** Запись снималась только на ветке отказа: после успешной выдачи она оставалась с непустым списком, а когда разрешение протухало, снять её было уже некому — следующего обращения к этому `authId` могло не быть никогда. В карте копились удалённые пиры и старые идентификаторы, переписанные импортом. Уборка идёт по фактической картине подключений в том же цикле учёта — единственном месте продукта, где она известна целиком. ### Исправлено — отзыв учётных данных и граница с Hysteria (тринадцатый проход) - **Смена секрета не отзывала доступ гарантированно.** Отзыв состоит из двух шагов, и второй умеет не удаться — сходимость обязан обеспечить цикл учёта. Но сверять ему было нечем: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, называлась тем же значением, что и законная, пир в базе существовал, доступ был открыт, устройств не больше разрешённого. Признака «установлена по недействительному секрету» в системе не существовало. Хуже того, у этого состояния есть путь **без единой неудачи**. Hysteria дожидается ответа backend-auth и только после `ok = true` помечает соединение аутентифицированным и сообщает о нём Traffic Stats API (проверено по исходникам `app/v2.12.2`), поэтому `/kick`, прошедший успешно, пока авторизация по старому секрету ещё выполнялась, этого соединения не видит. Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт, и повторным чтением базы перед ответом окно не закрыть. Теперь новое поколение учётных данных получает новый `auth_id`, а `/kick` идёт по старому: пережившая сессия становится orphan и завершается очередным циклом учёта — механизмом, который уже существует. Правило действует на обеих дверях к смене секрета, включая импорт, где случай «прежний `auth_id` + новый секрет» проходил мимо. Ротация происходит тогда и только тогда, когда меняется `secret_digest`. Цена названа прямо: трафик доживающей сессии за эти секунды не приписывается пиру и попадает в потери цикла. - **Адрес Traffic Stats API имел два контракта.** Оркестратор принимал любой IPv4 и честно подставлял его в `trafficStats.listen`, а проверка профиля сверяла конфиг с тем же значением — все гейты проходили. Админка при этом берёт из `listen` только порт и всегда идёт на `127.0.0.1`. Валидная по всем проверкам конфигурация разводила компоненты по разным адресам и выключала лимит устройств, учёт трафика и принудительное отключение разом: `/online` недоступен → авторизация fail-closed → не подключается никто. Адрес зафиксирован на `127.0.0.1`, а админка называет расхождение вместо молчаливой подстановки loopback. - **JSON-журнал Hysteria не разбирался ни одной строкой.** Юнит запускает Hysteria с `HYSTERIA_LOG_FORMAT=json`, но `time` в этом формате — число, и притом дробное (`EpochMillisTimeEncoder` делит наносекунды на миллисекунду), поэтому разбор в структуру со строковым полем падал всегда и уходил в fallback: панель показывала сырой JSON. Замена типа поля на `int64` не помогла бы. Разбор ведётся по фактическому формату, а структурный контекст записи (`addr`, `id`, `error`, `listen`, …) больше не выбрасывается, а дописывается к сообщению в устойчивом порядке и проходит санитайз. Заодно перестали теряться записи, у которых journald отдаёт `MESSAGE` массивом байт. - **«Служба остановлена» и «состояние неизвестно» были одним значением.** `util.Exec` выбрасывает вывод команды при ненулевом коде возврата, а `systemctl is-active` отвечает словом состояния в stdout вместе с кодом 3 — прочитать его было нечем. Дашборд из-за этого умел утверждать «Hysteria остановлена» и «Traffic Stats API доступен» одновременно: доступность API выводилась из того же ответа systemd, а не из обращения к API. Появился `util.ExecProbe`, состояние службы стало трёхзначным, а доступность API — независимым фактом. Список пиров при недоступном API отвечает «онлайн неизвестен», а не «все офлайн». - **Страница конфигурации показывала дефолты UI вместо файла.** Ответ сервера накладывался на полный объект значений по умолчанию, поэтому отсутствующая секция `trafficStats` показывалась как `:9999`, явное `speedTest: false` считалось ненастроенным, а `ignoreClientBandwidth` без блока `bandwidth` не показывался вовсе. Экран, существующий ради диагностики расхождений, эти расхождения скрывал. Теперь панель показывает записанные значения (отличая «не задано» от значения) и отдельно перечисляет секции вне production-профиля. Три редактора, которые ничего не сохраняли, удалены вместе с их компонентами. - **Читающий экран отдавал больше секретов, чем выгрузка.** Пароль обфускации, токены ACME DNS, учётные данные outbound-прокси и masquerade уезжали в браузер в открытом виде, хотя санитизированный экспорт того же конфига их вырезает. Теперь вместо значения показывается диагностический факт: «задан» / «не задан», имена параметров без значений, auth-URL с вырезанным токеном. - **Секрет за YAML-якорем покидал сервер.** Санитайзер выгрузки не обрабатывал `yaml.AliasNode`: значение по ссылке оставалось нетронутым, а объявление якоря стояло под несекретным именем ключа — секрет уезжал в файл дважды. Обход идёт по цели ссылки, с защитой от циклов: `yaml.v3` на ссылке, указывающей на предка, строит действительно циклический граф узлов. - **Hysteria больше не проверяет обновления сама.** В сборочном и e2e окружении `HYSTERIA_DISABLE_UPDATE_CHECK=1` стоял, а в production-юните — нет. Версией владеет один источник (`versions.env` → сборка → пакет → оркестратор), и production не имеет права отличаться от тестового окружения. - **Удалены мёртвые остатки прежней архитектуры.** `util.CompareVersion` (лексикографическое сравнение версий без потребителя: `2.10 < 2.9`), `service.ReleaseHysteria2` (пустая заглушка, вызывавшаяся при завершении сервиса), `PeerClientConfigVo.QrCode` (второй канал доставки QR, который панель рисует сама), компонент `UnitSelect` и три функции `utils/byte.ts`. - **Гейт освобождения admission-замка проверял форму, а не замок.** `/defer\s+\w+\(\)/` означало «в функции есть какой-нибудь отложенный вызов» и пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя переменной берётся из самого присваивания. - **Панель считала wildcard нормальным адресом control plane.** Признак на странице конфигурации отвечал только на вопрос «достучится ли админка», поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не создаёт. Теперь показываются три состояния: канон профиля, wildcard (предупреждение) и недостижимый адрес (ошибка); backend при этом по-прежнему отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех пиров. Заодно исправлено ложное утверждение в комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это все интерфейсы, а не loopback. - **«Какой-нибудь loopback» не означает «достижим по 127.0.0.1».** Проверка принимала любой `ip.IsLoopback()`, то есть считала рабочим и `127.0.0.5`. Слушатель на конкретном адресе принимает соединения только на него (`bind 127.0.0.5` + `dial 127.0.0.1` = connection refused, проверено), а слой proxy обращается строго к `http://127.0.0.1:`: такой адрес выглядел локальным, ломал контур доступа целиком, и админка об этом молчала. Принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост; свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает возврат `IsLoopback()` и требует негативного случая в тестах. ### Исправлено — устаревшие утверждения приёмки - **Гейт «frontend ACME registry matches current upstream» описывал снятую архитектуру.** Он требовал, чтобы страница Hysteria содержала жёсткий список ACME DNS-провайдеров (`cloudflare` … `vultr`) и не содержала удалённого upstream `namedotcom`. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в UI был вторым экземпляром upstream-реестра и мог от него отстать. После перевода страницы в read-only диагностику реестра нет и быть не должно — имя провайдера читается из фактического конфига и показывается как есть, поэтому новый upstream-провайдер отображается без правок панели. Возврат списка ради прохождения grep'а создал бы фиктивный реестр, существующий только для гейта, поэтому исправлен гейт: он проверяет действующий контракт (провайдер приходит строкой и рисуется как значение, параметры DNS — только именами, селектора на странице нет). То же утверждение поправлено в матрице приёмки, а контрактный тест панели теперь требует ОТСУТСТВИЯ всех восьми имён провайдеров в исходнике страницы. ### Исправлено — зависимости frontend - **`browserslist@4.21.11` содержал high-advisory** (уязвимы `<= 4.28.6`) и приходил транзитивно через `autoprefixer` и `update-browserslist-db`. Гейт сборки отработал ровно так, как задуман: `pnpm audit` по всему lock-графу остановил релиз до создания tarball. Закрыто точечным `pnpm.overrides` на `4.28.7` — точной версией, а не диапазоном: security-патч обязан быть детерминированным и не тащить за собой чужой major. Обновилось только поддерево `browserslist` (`caniuse-lite`, `electron-to-chromium`, `escalade`, `node-releases`, `update-browserslist-db`); `autoprefixer`, Vite и остальной граф не тронуты. ### Исправлено — гейты сборки - **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не возвращает успех из ветки ошибки» была записана регуляркой `err != nil \{[\s\S]*?return \*peer\.Id`, а ленивый `[\s\S]*?` свободно пересекает границы блоков: она давала совпадение на любой функции, где после какой-нибудь проверки ошибки ниже стоит успешный возврат. Проверено на коде из `HEAD` — гейт нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по балансу фигурных скобок. - **Детектор гонок стал обязательным шагом сборки.** Состояние трекера разрешений и мьютекс цикла учёта принадлежат процессу, поэтому их корректность не наблюдаема ни в `go test`, ни в `go vet`. Пропуск при недоступном C-компиляторе не предусмотрен: сборка, молча пропускающая проверку, выдаёт внешне неотличимый production-артефакт. ### Исправлено — отзыв доступа к VPN (десятый проход) - **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей жизнью и сама не разрывается. После «Отключить» пир продолжал пользоваться доступом сколько угодно долго, пока не переподключался по своей воле, — а панель показывала его отключённым. Официальная документация Hysteria описывает `/kick` и блокировку в auth backend как пару: по отдельности не работает ни одна половина. Появился отдельный примитив разрыва — только официальный Traffic Stats `/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял `banned_until`, поэтому воспользоваться им для отключения было нельзя: операция записала бы заодно временную блокировку — другой механизм с другим сроком жизни. (В одиннадцатом проходе он принимает готовые `authId`, а не идентификаторы пиров: удалению и импорту старое значение нужно уже после его исчезновения из базы.) Порядок обратному не подлежит: сначала долговременная запись, затем разрыв. При обратном клиент успевает переподключиться в окне между `/kick` и записью. Порядок доказывается снимком состояния базы В МОМЕНТ прихода `/kick` — после операции оба шага уже выполнены и проверять там нечего. - **Неудача разрыва не откатывает запрет.** Безопасная половина достигнута, и возвращать пиру полный доступ из-за отказа второго шага нельзя. Частичный результат сообщается отдельным кодом `peer_disconnect_failed`: без него оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» — вывод, прямо противоположный истине. Панель показывает его предупреждением и обновляет строку. - **Временная блокировка отчитывалась отказом, будучи применённой.** `KickPeer` писал `banned_until`, затем звал `Hysteria2Kick`, который писал то же значение второй раз, а до этого мог отказать на проверке состояния службы — не сделав ничего, но уже после первой записи. Обе операции переведены на один примитив. - **`Hysteria2IsRunning` убран с путей принятия решений.** `util.Exec` схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в одну ошибку, поэтому его ответ не является основанием ни для отказа операции, ни для её пропуска. Значению осталось место в отображении, где ошибочное «выключено» стоит одной неверной плашки. - **Ограничение устройств стало fail-closed.** Отказ `/online` обрабатывался возвратом успеха авторизации, то есть недоступность внутреннего `127.0.0.1` превращала объявленный в панели «Лимит устройств: 3» в безлимит; следом была только строка `warn` в журнале. Вторая половина той же дыры была тише: `Hysteria2Online` отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — и лимит переставал проверяться вообще без единой записи. Пути разделены по назначению: терпимый для отображения, строгий для решения о доступе. Направление выбрано осознанно: запрос авторизации приходит ОТ Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того же процесса. У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой отказ затрагивает всех пиров сразу — и пишется уровнем `error`. - **Секрет Traffic Stats API читался без проверки на nil** прямо на пути machine-auth, то есть при каждом подключении пира: строка в таблице `config` без значения уронила бы админку паникой на разыменовании. - **Ошибка публичного порта больше не называет транспорт.** Публичный endpoint Hysteria — UDP/QUIC, а сообщение утверждало `must be a valid TCP port`. ### Исправлено — журнал и страницы, которые его показывают - **Журнал запросов писал пустой `msg`.** `entry.Info()`, `entry.Warn()` и `entry.Error()` вызывались без аргумента сообщения, и logrus честно записывал `"msg":""` для каждого HTTP-запроса. Пустой столбец на странице системных логов был точным отражением того, что записал backend, а не рассогласованием модели отображения с форматом файла. `middleware.RequestLogMessage` собирает строку из тех же величин, что уже лежат в структурных полях. Query-строка в неё не попадает: контракт безопасности сильнее удобства чтения, и тест проверяет обе половины сразу — сообщение непустое И не несёт ни machine token, ни знака `?`. - **Страница системных логов не работала, пока нет файла журнала.** Эта ветка отвечала голым массивом вместо `{records, total}`, панель читала `data.records` и передавала `undefined` в таблицу. То есть на свежепоставленном хосте — ровно в сценарии приёмки чистой установки — страница была сломана целиком. - **Одна битая строка ломала весь ответ.** При неразбираемой записи выполнялись `vo.Fail(...)` И `continue`: ответ уже записан в поток, цикл идёт дальше, а в конце безусловно выполняется `vo.Success`. Клиент получал два JSON-документа подряд. Строка при этом бьётся штатно — lumberjack ротирует файл, и обрыв последней записи на границе ротации обычен. Теперь такая строка пропускается: остальные записи прочитаны и полезны. ### Исправлено — панель оператора, второй заход - **Быстрого включения/отключения пира в меню не было вовсе.** Сменить состояние можно было только через форму изменения, хотя это самое частое действие оператора. - **Меню действий переведено на `command`.** `@click` на каждом `el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда приходит в одно место, и добавить пункт, забыв его подключить, становится невозможно. - **Колонки таблиц журнала не задавали ширину.** Без `width`/`min-width` Element Plus делит доступную ширину практически поровну, и сообщение — единственное содержимое журнала — получало треть строки. Служебные колонки зафиксированы, колонка сообщения растягивается за счёт остатка и переносится, а не обрезается многоточием: у Hysteria в `msg` приезжает диагностический JSON. - **Обе страницы журнала построены на общем `LogViewer`.** Они были побайтово одинаковы и несли одни и те же дефекты в двух экземплярах; починка в двух местах разошлась бы снова. - **Отказ выгрузки журнала не ловился и глушился.** Сетевой запрос стоял ПЕРЕД `try`, поэтому его отказ этим `catch` не ловился вовсе, а сам `catch` был пуст. Оператор нажимал «Экспорт» и не получал ни файла, ни причины. - **Сборка ссылки на скачивание сведена в `utils/download.ts`.** Копий было четыре, и все успели разойтись: две ставили запрос вне `try`, третья падала на `split(...)` при отсутствующем `Content-Disposition` — и это исключение тоже глушилось. Четвёртую копию (выгрузка конфига Hysteria) нашёл контрактный тест, потребовавший единственности `createObjectURL`. - **Ширина всплывающей подсказки ограничена штатным `popper-style`.** Без ограничения длинный перевод получал естественную ширину popper и растягивался почти на весь экран одной строкой. - **Срок временной блокировки называется оператору.** `Date.now() + час` был зашит в обработчик, не подтверждался и не сообщался ни до, ни после; ключи локализации для него существовали и были мёртвыми. Пункты переименованы так, чтобы «временно заблокировать» не путалось с «отключить пир». - **Отмена подтверждения перестала быть ошибкой.** `ElMessageBox` отклоняет промис при нажатии «Отмена», и `await ElMessageBox.confirm(...)` без разбора отказа оставлял необработанное отклонение промиса — в четырёх местах страницы пиров и ещё одном в верхней панели. - **Подсказка имени пира описывала не действующее правило.** Она обещала «латиница, цифры и дефис», тогда как `service.IsValidPeerName` принимает `a-z A-Z 0-9 !@#$%^&*()_+-=`: подсказка осталась от правила, действовавшего до EX-03, и оператор не имел причин пробовать разрешённые символы. Тест читает набор из серверной константы `PeerNameCharset`. - **Типы полей журнала в панели расходились с сервером** (`latencyTime` и `statusCode` объявлены строками при `int64` на сервере). Пока колонки не отображались, расхождение было безвредным. - **Диагностика слоя proxy сообщала `err: ` именно при отказе API.** Ветка `err != nil || resp.StatusCode != http.StatusOK` печатала `err`, то есть при ответе Hysteria с кодом, отличным от 200, в журнал уезжала запись без единого факта. Различать эти два случая стало обязательно: на этом пути теперь стоит отзыв доступа. - **Отказ показывался дважды, а транспортный — ни разу.** Действия строки пира не объявляли `skipErrorToast`, поэтому после появления собственных сообщений отказ показывался и перехватчиком, и страницей: у частичного результата отзыва доступа это давало предупреждение «состояние применено наполовину» и рядом ошибку — два противоречащих сообщения об одном событии. Обратная половина существовала и раньше: флаг гасил не только отказ API, но и ТРАНСПОРТНЫЙ отказ, при том что все объявлявшие его страницы писали в своих обработчиках «транспортный отказ уже показан общим перехватчиком» и молчали. Обрыв соединения при сохранении или удалении пира не показывал оператору ничего. Флаг отнесён только к отказу API — тому, у которого есть конверт с `code` и `errors`, то есть есть что разбирать. - **Из globals ESLint убраны `DialogType` и `OptionType`** — их нет ни в `src/types/global.d.ts`, ни где-либо в панели; список приведён к фактическому содержимому файла объявлений. Добавлены `IdDto`, `BaseDto` и `PageVo`, объявленные там на самом деле: `no-undef` не знает о `declare global`, и отсутствующее в списке имя падало ошибкой линтера на совершенно корректном коде. ### Исправлено — панель оператора - **Необязательный секрет пира был фактически обязателен.** Панель обещала «оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`. В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле объявлено указателем и указатель не nil: помощник `hasValue` считает указатель на пустую строку «значением». Правило `min=6` применялось к пустой строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой. Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница проходит по каждому полю отдельно — у `remark` пустая строка означает «убрать пометку», у `disabled` ноль означает «включён», и общее правило «пусто → не задано» молча сломало бы оба. Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в один клик по крестику. - **Генерация секрета названа явным шагом сервисного слоя.** `service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов) используется и формой, и импортом: пир, созданный панелью, и пир, импортированный без секрета, теперь неотличимы. - **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами — тот же антипаттерн, который запрещён панели, только на сервере. Ответ об ошибке теперь несёт `errors: [{code, field, message, params}]`; панель выбирает локализованную фразу по коду и подставляет причины под поля формы. Границы числа и границы длины строки различаются кодом, хотя тег валидатора у них один: оператору это разные фразы. - **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе — а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения ошибок вместо свежих строк, middleware различает истечение и недействительность через `errors.Is`, а панель показывает диалог и возвращает на форму входа — один раз, даже когда истёкший токен уронил несколько параллельных запросов страницы. - **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`, не проверив `error.response`, и при обрыве соединения подменял настоящую причину `TypeError` внутри себя. - **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал выбранный оператором язык панели. - **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал `IdDto` с правилом `required`, хотя значение из тела всё равно затирается значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик читает следом второй раз, а gin его не буферизует. ### Исправлено — отрисовка иконок - **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor` стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из семнадцати ассетов несли литеральный `fill="#000000"` прямо на ``, а атрибут представления перебивает унаследованное CSS-свойство. Под это попадали все семь иконок бокового меню на фоне `#181818`. Литеральный цвет убран из ассетов; многоцветные объявлены явным списком; преобразование в `` и контракт ассета вынесены в чистый модуль `SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит, проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация скрывала бы ровно тот дефект, который контракт обязан делать видимым. - **У `SvgIcon` убран проп цвета** и атрибут `fill` на ``: он приглашал чинить отрисовку точечно в обход общего контракта. ### Исправлено — правила имени пира - **Два правила на одном поле противоречили друг другу.** Стояли `min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх символов проходило одно правило и отказывалось на другом. Длина перенесена внутрь одного правила. - **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её комментарий при этом утверждал, что набор тот же, что у импорта. Через панель проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет. Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими дверями в таблицу пиров. Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно, но повторяет прежнее фактическое множество. Имя администратора приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила означало бы, что установка с логином вроде `admin.ops` перестаёт пускать оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести порядок» роняла сборку, а не вход на живом сервере. ### Добавлено — атрибуция и контрактные тесты панели - **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни из таблицы `config`. Высота области прокрутки меню вычитает высоту подписи, поэтому пункты меню не могут на неё наехать. - **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали обязательным шагом сборки наравне с тестами оркестратора и админки: контракт спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов ошибок серверным константам, единственность адреса атрибуции. Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не вычисляет `currentColor` и визуальной корректности всё равно не доказал бы, зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему lock-файлу frontend — сотню транзитивных зависимостей ради нулевой дополнительной гарантии. ### Изменено — документация - **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов: `architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`, `acceptance/`. Двузначный префикс сохранён как стабильный идентификатор документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения оркестратора. - **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей** по слоям, на которых выполняются проверки. - **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки, отдельно от описания самих проверок. Документ проверок переживает релизы; отчёт о прогоне относится к одному артефакту и одному хосту и после публикации не редактируется. Первый отчёт — build/host acceptance `v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия. - **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не проверяются ни типами, ни сборкой bundle. - **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без swap он был убит OOM killer. - **Обход документации в приёмке стал рекурсивным.** Плоский шаблон `docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом, то есть проверка отчитывалась бы зелёным, не заглянув почти никуда. ### Исправлено — гейты сборки - **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.** Такой поиск прекращает чтение на первом совпадении и закрывает свой конец канала; продюсер, которому осталось что писать, получает `SIGPIPE` и завершается кодом 141, а `pipefail` делает 141 статусом всей конструкции: ```text совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено» ``` Для утвердительных проверок это ложный FAIL. Для отрицательных — «такой конструкции в коде нет» — **ложный PASS**: запрещённая конструкция найдена, а гейт зелёный. Именно отрицательными проверками закреплена половина инвариантов приёмки, включая запрет обхода тестов и запрет `pnpm audit --prod`. Порог резкий и измеримый: пока вывод продюсера помещается в буфер канала (64 KiB на Linux), он не блокируется и успевает завершиться раньше, чем потребитель начнёт читать, — 0 отказов на 60 прогонах для любого размера до 60 KiB и 58/60 ровно на 64 KiB. То есть проверка выглядит исправной ровно до первого источника крупнее буфера, а такие файлы в репозитории уже есть. Все 56 мест переведены на here-string (`grep -q PATTERN <<<"$content"`); продюсеры-команды (`ss | awk`, `dpkg-query`, `/proc/cpuinfo`, `systemctl list-unit-files`) сначала читаются в переменную. Возврат пайплайна запрещён отдельной приёмкой. - **Корректность десяти отрицательных сканов держалась на `|| true`.** В `code_without_comments` подавление статуса гасило и SIGPIPE-141, поэтому сканы работали — но по причине, которую никто не заявлял и которую снял бы первый же, кто убрал бы маскировку ошибок. Введён `code_has`, читающий код в переменную отдельным оператором. - **Три отрицательных скана падали на прозе, а не на коде.** Комментарий, объясняющий, почему чего-то больше нет, называет это по имени — и скан по голой подстроке не отличает такой комментарий от кода: ```text скан иконок -> блочный комментарий о замене плагина скан имён раннеров -> слово `systemd-run` в прозе скан cancelFirewall… -> комментарий о разделении функции ``` Все три исправлены по форме, а не удалением комментариев: скан раннеров требует, чтобы перед именем не стоял дефис (в JavaScript идентификатор после дефиса не начинается, поэтому ни один настоящий вызов не пропускается); скан `cancelFirewallRollback` ищет имя со скобкой, то есть объявление или вызов; литеральный скан по `virtual:svg-icons-register` удалён, потому что его роль исполняет более сильный и более ранний гейт — production `vite build`, где неразрешимый виртуальный модуль роняет сборку bundle. Из них скан `cancelFirewallRollback` был сломан с момента появления и не падал только потому, что сборка до него не доходила. - **Несуществующий путь в скане означал успех.** `2>/dev/null || true` в `code_without_comments` превращал опечатку в имени файла в пустой вывод, а пустой вывод для проверки «запрещённой конструкции здесь нет» — это PASS. Теперь отсутствующий файл останавливает сборку. Проверка явная, а не через `set -e`: в контексте `! code_has …` bash отключает `errexit` на весь вызов. ### Исправлено — барьер покоя и контракт транзиентного таймера - **Отказ запроса к systemd выдавался за отсутствие guard'а.** Листинг guard-юнитов при исключении возвращал пустой список, и пустой список означал «можно начинать»: ```text systemd жив, старый rollback timer взведён -> запрос к systemctl/D-Bus временно отказывает -> список guard'ов пуст -> барьер считает систему спокойной -> новая операция начинает менять firewall -> старый таймер срабатывает поверх неё ``` Обоснование в комментарии («без systemd не может быть и транзиентного таймера») доказывало не то: отказ запроса не означает, что systemd нет. Барьер стал fail-closed и получил отдельный тип отказа `GuardStateUnknownError` — «guard вооружён» и «спросить не удалось» требуют от оператора разных действий. Практического выигрыша у прежнего поведения не было: `systemd-run` требуется в preflight, поэтому без работающего systemd операция всё равно отказывала — просто позже и менее внятно. - **Покой перечислялся чёрным списком.** Непокойными считались `active`, `activating`, `deactivating`, `reloading`, а покоем — «всё остальное», то есть любое состояние, которого автор не назвал. systemd 257 знает ещё `maintenance` и `refreshing`. Политика инвертирована: покой — это `inactive` и `failed`, всё прочее блокирует операцию. - **Обещанные «45 секунд» не были контрактом systemd.** `OnActiveSec=45s` не означает «ровно через 45 секунд»: таймер вправе сработать в окне `[цель; цель + AccuracySec]`, а умолчание `AccuracySec=` — одна минута. То есть README, docs и текст отказа обещали 45 секунд, а контракт допускал 105. Теперь точность задаётся явно (`AccuracySec=1s`), и реальное окно — 45–46 секунд. - **Выгрузка отработавшего таймера держалась на чужом умолчании.** Право барьера считать исчезновение юнита покоем опирается на `RemainAfterElapse=no`. `systemd-run` выставляет это свойство транзиентным таймерам сам, но инвариант, который нигде не записан и ничем не проверяется, инвариантом не является. Свойство задаётся явно, а барьер дополнительно опознаёт `SubState=elapsed` у `*.timer` как покой: `TIMER_ELAPSED` в systemd отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер, созданный не нами, блокировал бы `repair` навсегда. - **`status` расходился с барьером в трактовке того же самого guard'а.** У него была своя копия листинга — без `--plain` (у `failed`-юнита первой колонкой идёт маркер `●`), с `|| true` (отказ systemd превращался в «guard'ов нет») и без разбора состояний: вооружённым считался любой найденный юнит. Аварийно сработавший guard оставляет `failed`-сервис загруженным до `reset-failed`, поэтому `status` вечно показывал `firewall_state: guard_active`, пока барьер тот же юнит считал покоем и разрешал `repair`. Копия убрана: отчёт берёт состояние у барьерного наблюдателя и сообщает `rollback_guard_state` (`quiescent` / `pending` / `unknown`) с `active_state` и `sub_state` каждого юнита. - **`purge-v0.sh` не снимал именно аварийно сработавший guard.** Разбор `awk '{print $1}' | grep -E '^hy2xs-fw-rollback-'` отбрасывал строку целиком, когда первой колонкой стоял маркер `●`, то есть пропускал `failed`-юниты — единственные, ради которых эта проверка написана. - **Контракт команды взведения проверялся грепом по исходнику.** Команда собиралась интерполяцией в shell-строку и существовала только в момент запуска. Теперь её строит чистая `buildArmGuardArgv`, а выполняет `runMutatingArgv` — без shell вообще, что заодно убирает вопрос о квотировании из команды, создающей systemd-юнит с именем из данных операции. Свойства таймера стали обычным значением, которое сравнивает обычный тест. - **Барьер не имел ни одного поведенческого теста.** Все проверки были грепами по тексту функций, и один из них закреплял как раз небезопасное поведение: тест утверждал, что в теле есть `return [];`. Строка была на месте — а решение при этом стало неверным. Наблюдение за systemd вынесено в подставляемый `SystemdUnitProbe`, и сценарии («systemd не ответил», «таймер взведён», «сервис упал», «незнакомое состояние», «таймер отработал») проверяются поведением, без systemd и без Linux. ### Исправлено — границы транзакции - **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации успеха выглядел так: ```text systemctl stop .timer .service || true -> "firewall rollback timer disarmed" -> phase=installed ``` Между «мы думаем, что guard снят» и «guard действительно снят» не было ни одной проверки: `|| true` стирал код возврата, и взведённый таймер мог вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true` было нельзя — для транзиентного юнита, уже убранного systemd, `systemctl stop` возвращает 5, и этот исход неотличим от успеха. Введён маркер `/run/hy2xs/rollback//auto-rollback-fired`, который rollback-скрипт создаёт первым действием. Снятие guard стало доказательством: маркер отсутствует, `ActiveState` обоих юнитов равен `inactive`, и только после этого записывается `phase: installed`. - **Автоматический откат мог сработать во время успешного smoke, и операция этого не замечала.** Окно guard — 45 секунд — заведомо короче худшего случая smoke, а единственной проверкой firewall в smoke был `nft -c`: разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset проходил её зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall — особенно дорого при смене порта Hysteria, SSH или ACME. Лечится не увеличением окна: сработавший guard теперь запрещает фиксацию успеха независимо от результата smoke и даёт собственную причину отказа `firewall_guard_fired`. Дополнительно smoke сверяет эффективный firewall с конфигурацией операции — фрагмент правил, принадлежность entrypoint и фактически загруженную таблицу `inet hy2xs`. - **У оркестратора не было блокировки операций.** Ни `flock`, ни mutex, ни lockfile — при том что вся архитектура отката опиралась на невысказанное допущение об одной операции за раз. `install-state.json` замком не является: это запись о состоянии, а не право на изменение. Два одновременных `reconfigure` доходили до конца каждый по-своему, и уникальные `op-id` не спасали — они разделяют резервные копии, но production paths общие. Дальше любая из операций могла упасть и «восстановить» состояние поверх изменений другой, отчитавшись полным успехом. Введён эксклюзивный замок `/run/lock/hy2xs-orchestrator.lock`. `install`/`reconfigure`/`repair`/`doctor` берут его и отказывают **до первой мутации**; `status`/`diagnostics` не берут, но сообщают об идущей операции; `preflight-install` отказывает до собственных проверок. Замок снимается при любом завершении держателя, включая обрыв SSH. - **Автоматический откат маскировал собственные ошибки.** Внутри `systemd-run` оставались `cp ... || true` и `nft -f ... || true`, поэтому при частичном восстановлении юнит завершался кодом 0 — ровно в сценарии, где guard является последней линией защиты от потери SSH. Скрипт переписан: независимые стадии, накопление кода возврата, `failed` с диагностикой в journal. - **Откат не восстанавливал состояние `nftables.service`.** `applyFirewall` выполняет `systemctl enable --now nftables`, но копия хранила только файлы правил. После отката неудачной первой установки сервис оставался включённым в автозапуск, хотя до неё был выключен. Состояние снимается вместе с файлами и восстанавливается стадиями, идущими до применения ruleset: у `nftables.service` `ExecStop=nft flush ruleset`, и обратный порядок стёр бы восстановленные правила. - **`/etc/nftables.conf.candidate` не удалялся никогда.** Успешная установка оставляла его на сервере навсегда. Candidate-файлы убираются после успеха и best-effort при откате; `purge-v0.sh` тоже их знает. - **Скрипт автоотката собирался однострочником внутри `sh -c '...'`.** Интерполяции проходили через shell-квотирование и подставлялись внутрь уже закавыченной строки: корректность держалась на склейке соседних кавычек и на том, что op-id не содержит пробелов. Скрипт вынесен в отдельную чистую функцию, ключ операции проверяется, а результат покрыт тестом и разбирается настоящим shell-парсером. - **Ключ операции считался в двух местах и разошёлся.** `install` писал в маркер сырой ISO-timestamp с двоеточиями, тогда как каталог отката назывался санитизированным ключом: путь `/run/hy2xs/rollback/`, который runbook предлагает открыть, на сервере не существовал. - **Стадии восстановления `reconfigure` были независимы по группе, а не по файлу.** Отказ `cp` для `hy2xs-admin.service` отменял восстановление `hysteria-server.service`: внешняя стадия честно попадала в список отказавших, но принцип «восстановить максимум» на уровне файлов не выполнялся. - **Отказ записи маркера `auto-rollback-fired` оставался незамеченным.** Инвариант фиксации — «маркера нет и юниты `inactive` ⇒ guard не сработал» — верен только при дополнительном условии «guard способен записать маркер». Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs `/run`, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall, завершался кодом 0, юнит уходил в `inactive`, маркера не было — и операция фиксировала успех после реально сработавшего отката. Теперь у факта срабатывания два независимых канала: маркер и отказ юнита. Отдельно: маркер создаётся `touch`, а не `: >file`. Двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана завершить неинтерактивный shell целиком; в dash, который на Debian и есть `/bin/sh`, скрипт умер бы ДО восстановления firewall. - **Новая операция могла начаться, пока guard предыдущей ещё вооружён.** Замок и guard вводились по отдельности и оставляли дыру на своём стыке. Замок действует, пока жив процесс-держатель; guard — отдельный объект systemd, который свой процесс переживает: ```text A берёт замок -> применяет firewall -> вооружает guard на 45 секунд A аварийно умирает B берёт замок и начинает менять production paths guard A срабатывает и возвращает firewall, который был ДО A ``` Случай с `SIGTERM`/`SIGHUP` при этом хуже, чем `kill -9`: обработчик снимает замок сам, поэтому проверка живости держателя не видит вообще ничего, а таймер остаётся. Введён барьер покоя, через который проходит каждый захват замка — и PHASE 0 установщика тоже. Условие старта стало «у предыдущей операции не осталось исполнителей, способных изменить систему». - **Восстановление `UnitFileState` обещало точность, которой не давало.** `systemctl enable --runtime` не удаляет постоянную ссылку, поэтому «восстановление» состояния `enabled-runtime` оставляло юнит включённым в обоих scope'ах. Теперь восстанавливаются `enabled` и `disabled` — состояния, которые операция реально меняет, — а остальные явно называются оператору и не трогаются. ### Исправлено — целостность отката - **Данные для отката уничтожались до фиксации успеха.** Успешный install заканчивался вызовом, который снимал таймер автоотката **и удалял резервные копии firewall**, — и стоял этот вызов ДО долговечной записи `phase: installed`. Если запись падала (`ENOSPC`, `EIO`, read-only ФС), управление уходило в обработчик ошибки, обязательный откат честно запускался и сообщал `no HY2XS rollback markers found`. Откат нельзя было отменить, но откатывать ему было нечем — причём отказ записи маркера это ровно тот сценарий, который был специально сделан безопасным прошлым проходом. Операция разделена на `disarmFirewallRollback` (снять таймер, копии оставить) и `cleanupFirewallRollback` (удалить копии). Порядок теперь: `smoke_ok` → disarm → durable `installed` → cleanup best-effort. То же в `reconfigure`. - **Резервные копии снимались без доказательства.** И firewall, и `reconfigure` копировали файлы как `cp ... || true`, поэтому отказ копирования игнорировался, а операция начинала менять систему, не имея копии, на которую рассчитывает откат. У firewall маркер `prepared` («данные для отката существуют») выставлялся вообще до копирования. Копирование стало строгим, факт создания проверяется, маркер ставится после. - **Копии `reconfigure` смешивались между операциями.** Они лежали одним общим набором `*.bak` в `/etc/hy2xs/backups`, не привязанным к проходу. Если у операции B копирование падало, B всё равно менял систему, а его откат восстанавливал файлы, сохранённые операцией A: сервер возвращался не в состояние «до B», а в более старое — и это выглядело успешным откатом. Копия стала операционной: `/etc/hy2xs/backups//` с манифестом, где отсутствие файла — записанный факт (`"present": false`), а не вывод из неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`. - **Ошибка восстановления скрывалась, а копии после неё удалялись.** `rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем безусловно удаляла `/run/hy2xs/rollback/`. Худшая возможная комбинация: неудача восстановления не видна, стадия отчитывается успешной, а данные, по которым оператор мог бы поднять firewall вручную, уничтожены. Теперь копии удаляются только после подтверждённого успеха, иначе сохраняются с сообщением `manual recovery data preserved at …`. - **Команды отката глушили собственный код возврата.** `systemctl stop`, `disable`, `reset-failed`, `cp`, `nft -f`, `daemon-reload`, `restart` — все несли `|| true`. До появления стадийного раннера это была единственная защита от обрыва цепочки; после его появления — маскировка: стадия физически не могла сообщить, что ничего не сделала, и обещание «отказавшие стадии перечисляются» для них не выполнялось никогда. `|| true` убран, непрерывность обеспечивает раннер, `rollbackCurrentState` разбита на семь независимых стадий. - **Долговечность записи каталога маркера.** `writeTextAtomic` синхронизирует файл и каталог, в котором файл лежит, но при первой установке `/var/lib/hy2xs` создаётся тут же, и запись «hy2xs» в `/var/lib` оставалась несинхронизированной: после потери питания мог исчезнуть весь каталог вместе с маркером. `ensureDir` сообщает о фактическом создании и синхронизирует родителя только тогда. ### Изменено — свойства, ставшие инвариантами - **`doctor` read-only по инварианту рантайма, а не по соглашению.** Принудительный `skipServiceStart` закрывал ровно одну ИЗВЕСТНУЮ мутацию — рестарт сервисов. Всё остальное в `smoke` держалось на том, что автор правки выбрал правильный раннер, а читающие команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть настоящая мутация, случайно добавленная в `smoke`, ничем бы от них не отличалась и была бы разрешена в `doctor` молча. Эти команды классифицированы честно, ожидание между попытками перестало быть подпроцессом `sleep` через мутирующий раннер, а сам `doctor` целиком выполняется под тем же read-only guard, что и PHASE 0 установки. Диагностика при этом не сузилась. ### Исправлено — `reset-admin` - **Отказ базы трактовался как «администратора нет».** Слой данных специально различает `ErrAdminUserNotFound` и `ErrStorage`, но команда восстановления доступа склеивала их обычным `if err != nil { создать } else { обновить }`. Опасен здесь не только нарушенный смысл sentinel'ов: при транзиентном отказе чтения («database is locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ учётные записи администратора. `GetAdminUser` берёт `First()` и о второй строке не сообщает — то есть на сервере оставалась вторая рабочая учётка с паролем, уже напечатанным на экран, и ни один запрос об этом не говорил. - **Ошибка хеширования пароля проглатывалась.** В ветке обновления стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При отказе bcrypt в `password_hash` уезжала пустая строка, а на экран печатался пароль, которым войти уже невозможно: `VerifyPassword` отклоняет всё, что не является bcrypt-хешем. Команда восстановления доступа умела молча его отобрать. ### Исправлено — восстановление после неудачной операции - **Запись состояния отказа отменяла откат.** Обработчик ошибки в `install` и `reconfigure` первым делом писал в `install-state.json` фазу отказа обычным `await` и только потом откатывался. Эта запись — `mkdir`, `write` и `chown` в `/var/lib/hy2xs`, то есть она падает ровно там, где откат нужнее всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил управление наружу, и обязательное восстановление не выполнялось вовсе — применённый firewall и развёрнутые сервисы оставались на сервере. Необязательная телеметрия состояния стояла перед обязательным восстановлением. Для сбора диагностики это уже было закрыто прошлым проходом, для записи состояния — нет. Теперь запись обёрнута так же: неудача попадает в журнал строкой `failed to persist failure state, continuing with the mandatory rollback`, а откат продолжается. - **Откат отменял сам себя.** Он был написан цепочкой `await`, а каждая его стадия — `systemctl`, `cp`, `rm -rf` или `nft`, то есть умеет упасть сама. Отказ первой стадии отменял все последующие. В `reconfigure` это означало сервер одновременно с применённым сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов — худший сценарий отказа лишался обеих половин восстановления сразу. Внутри `rollbackCurrentState` болезнь была та же: единственная команда без `|| true` (`systemctl daemon-reload`) отменяла перезапуск сервисов строкой ниже, и восстановленные unit-файлы так и не применялись. Стадии стали независимыми: выполняются все и в объявленном порядке, отказавшие перечисляются в журнале, наружу уходит исходная ошибка операции. - **У маркера установки было два писателя с разными гарантиями.** `install` перезаписывал файл на месте, `reconfigure` подставлял атомарно; слабейшая гарантия досталась команде, которая этот файл создаёт. Перезапись на месте укорачивает файл до нуля и только потом наполняет — отказ между этими моментами оставляет половину JSON, которая не разбирается: `reconfigure` видит такой маркер как отсутствующий, clean-host — как присутствующий, а хост к этому моменту уже изменён. Атомарности при этом было бы мало: `rename()` без `fsync` даёт атомарность видимости без долговечности, и после потери питания ext4 штатно отдаёт по этому пути нулевой файл. Порядок теперь: права и владелец → `fsync` файла → `rename` → `fsync` каталога. - **Ownership-флаг маркера отвечал не на тот вопрос.** Он назывался `stateWritten` и взводился ПОСЛЕ успешной записи, хотя запись — это три операции. Отказ на `chown` оставлял файл на диске при невзведённом флаге, то есть давал классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже существующем `/var/lib/hy2xs/install-state.json`, который ломал следующую чистую установку. Флаг переименован в `stateTouched` и взводится до первой операции записи, как все остальные. ### Изменено — релизные гейты сборки - **`pnpm audit` проверяет весь lock-граф, а не production-подграф.** Гейт запускался с `--prod` под обоснованием «devDependencies в артефакт не попадают». Для frontend build tooling это неверно по существу: `vite` и `rollup` не копируются на сервер, но исполняются на build-машине и порождают тот самый production-бандл. Ровно такой случай и был найден в этом же релизном цикле — DOM clobbering в Rollup затрагивал генерируемый бандл, а `--prod` его не показывал; по всему графу тот же прогон дал 33 предупреждения против нуля. Критерий приёмки №47 в `docs/11` формулировал это правильно ещё до того, как стало правдой в коде. - **Удалён `SKIP_TESTS`.** Переменная была описана как «аварийное отключение тестов; для release-сборок недопустимо». Недопустимость держалась исключительно на этой фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты запускались, поэтому `SKIP_TESTS=true ./tools/build/build.sh` доходила до конца и выдавала обычный tarball с `build_profile=production` и `dependency_security_gate=true` — артефакт, по которому невозможно отличить проверенную сборку от непроверенной. Глушила она при этом не только тесты, но и `tsc --noEmit` с `go vet`. Выбран тот же строгий вариант, что и для проверки зависимостей: обхода нет, а готовый пакет объявляет `tests_gate=true` в `metadata/package.env`. Поле опирается на фактический прогон — `write_metadata` отказывается писать метаданные, если хотя бы один из двух прогонов не подтверждён. Для локальной работы обходить нечего: `bun test`, `tsc --noEmit`, `go vet` и `go test` запускаются напрямую и tarball не создают. ### Исправлено — операции, не выполняющие обещанного - **Удаление `bootstrap-admin-peer` не было отзывом доступа.** Признаком «создавать пир или нет» служило наличие строки в таблице пиров, а `HY2XS_ADMIN_CON_PASS` продолжает жить в `/etc/hy2xs/hy2xs.env` — его читает systemd-юнит. Поэтому оператор удалял пира, доступ действительно исчезал, а ближайший `systemctl restart hy2xs-admin` или перезагрузка сервера возвращали того же пира с тем же секретом. Молча: ни строки в журнале, в списке пиров запись просто снова есть. Признаком стала отметка `BOOTSTRAP_PEER_SEEDED` в таблице `config`: она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас». Отметка и сам пир пишутся одной транзакцией — раздельная запись вернула бы прежнее поведение при падении процесса между двумя операциями. Удаление осталось разрешённым и стало необратимым; отключение (`Disabled = 1`) остаётся вторым, обратимым способом. - **Резервная копия с секретами могла молча оказаться неполной.** `ListExportPeer(true)` проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым полем `secret` и успешный ответ. Оператор, СПЕЦИАЛЬНО выбравший режим «копия с действующими credentials», получал файл, выглядящий полным, и узнавал о потере после импорта на новом сервере — по отвалившимся клиентам. Теперь недоступный секрет хотя бы одного пира отклоняет весь запрос с указанием имени; файл не создаётся. Безопасная выгрузка (`includeSecrets=false`) не изменилась. - **`DecryptPeerSecret` возвращала содержимое колонки как расшифрованный секрет,** если оно не начиналось с `v1:`. Ветка досталась от поколения, где секреты пиров лежали открытым текстом; при clean-install-only политике такой строки существовать не может, а вред оставался: повреждённая колонка уходила в клиентскую ссылку и в резервную копию как учётные данные. Формат хранения теперь ровно один, всё остальное — ошибка. Тот же класс, что и удалённый SHA-224 fallback при входе. - **`hy2xs-orchestrator doctor` перезапускал оба сервиса.** Команда собирала контекст с параметрами по умолчанию и звала общий smoke, который начинается с `systemctl restart hysteria-server hy2xs-admin`. То есть диагностика, которую runbook предлагает запускать при подозрении на проблему, гарантированно обрывала все живые VPN-соединения — включая случай, когда с сервисом всё в порядке. Диагностика, меняющая то, что диагностирует, отвечает не на заданный вопрос. `doctor` принудительно выставляет `skipServiceStart`; остальные проверки smoke выполняются полностью. - **Админка сама придумывала `HYSTERIA2_TRAFFIC_STATS_SECRET`.** При пустом env и пустой базе она генерировала случайный токен, записать который в `/etc/hysteria/config.yaml` не может — файл принадлежит оркестратору и доступен ей только на чтение. Сервис объявлял себя здоровым, а machine auth переставал совпадать, потому что Hysteria продолжала слать прежнее значение. Тот же принцип, что уже действует для `HY2XS_ADMIN_INITIAL_PASSWORD`: пустой env при пустой базе — отказ старта; уже согласованный токен в базе принимается. - **Обходы проверки зависимостей существовали только на бумаге.** Документированные `dependency_security_gate=accepted-risk` и `skipped` не могли произвести артефакт: финальная приёмка архива требует буквально `true`, поэтому сборка с ними проходила весь цикл и падала на последнем шаге. Продукт документировал операцию, которую сам же запрещал. Обе переменные удалены из сборки и документации; их отсутствие проверяется приёмкой. Контракт стал однозначным: релизный артефакт невозможно собрать с непройденной проверкой. - **`UPDATE` по отсутствующей строке `config` считался успехом.** `updateConfigOn` смотрел только на `tx.Error`, а UPDATE без совпавших строк ошибкой SQL не является. Следом `applyRuntimeConfigUpdates` применял значение к планировщику, поэтому оператор дважды получал подтверждение изменения, которого не произошло, а перезапуск сервиса возвращал прежний cron. Решение принимается по `RowsAffected` — как в соседней `UpsertConfigValue`, где эта же ошибка уже была разобрана. - **Слой данных не отличал «записи нет» от «база не ответила».** Каждый `Get*` возвращал свежий `errors.New` со строкой, поэтому отказ SQLite был неотличим от отсутствия записи, а решения на этом принимались серьёзные: «пира нет» означало «создать заново», «по auth_id не нашли» — «искать по имени и, не найдя, создать», «ошибка» в `ExistPeerName` — «имя свободно». При недоступной базе продукт не отказывал, а трактовал отказ как разрешение действовать. Введены sentinel-значения `ErrPeerNotFound`, `ErrAdminUserNotFound`, `ErrConfigNotFound` и `ErrStorage`. - **Алиасы `/:id/client-url` и `/:id/qr` удалены.** Они были оставлены «на один миграционный релиз», которого у clean-install-only продукта не существует; дожив до `1.0.0`, они стали бы частью публичного API v1. ### Добавлено — контракт разработки - **`apps/go.mod` объявляет `toolchain go1.26.7`.** Директива `go` — языковой baseline модуля, а не выбор компилятора: с ней одной локальный `go build` на другой минорной линии проходил успешно, хотя релизный бинарь собирается на 1.26.7 и наследует **её** stdlib. Разработчик и сборка проверяли разный код. Совпадение с `versions.env` проверяет `verify_go_toolchain_contract`. - **`tools/dev/doctor.sh` и `tools/dev/doctor.ps1`** сверяют Go, Node, pnpm, Bun и директиву `toolchain` с `versions.env`. Собственных значений версий у них нет — второй список неизбежно разъехался бы с контрактом. Сборка соблюдала `versions.env` и раньше; машина разработчика не проверялась никак, и расхождение обнаруживалось на Debian, внутри release-сборки. ### Изменено — модернизация frontend Известное ограничение «проверка типов frontend почти ничего не проверяет» закрыто и удалено из документации. - **`vue-tsc` стал настоящим release gate.** Он был версии `0.35.0` (2022 год) и шаблоны Vue не типизировал: проверка проходила зелёной, не давая обещанной гарантии. На Vue 3.5 она к тому же ломается сама, не зная `vue/jsx-runtime`, — то есть пережить обновление Vue не могла в любом случае. Современный `vue-tsc 3.3` на том же коде дал **142 ошибки**, а не ~155 из прогноза, и картина оказалась однороднее: 141 × `TS18048` и одна `TS2322`, всё в двух файлах представления Hysteria. Ожидавшегося класса «`DefaultRow` несовместим с `PeerVo`» на Element Plus 2.3 не существовало — он появился вместе с обновлением Element Plus до 2.14. Скрипты разделены: `typecheck`, `build:prod`, `verify`. Сборка запускает проверку типов **до** bundle; раньше `vite build && vue-tsc` сначала тратил время на production bundle и только потом сообщал о типовой ошибке. - **Нормализация конфига Hysteria на границе API.** Все 142 ошибки — обращения к необязательным секциям конфига в шаблоне. Необязательны они правильно: так устроен upstream YAML. Инвариант «секция есть всегда» существовал, но держался на порядке присваиваний внутри компонента и типом не выражался. Введён `Hysteria2ServerConfigView`, выводимый из модели ответа типом, а не вторым списком полей, — вместо 141 оператора `?.` или `as any`. - **Убраны три редактора, которые ничего не сохраняли.** Outbounds, список значений и словарь «ключ — значение» на странице конфига Hysteria: страница отрисована с `:disabled="true"`, значения передаются без `v-model`, маршрутов записи серверного конфига в API нет. Оператор мог добавить outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию сервера. У одного цена была ещё и измеримой: `vuedraggable` поставляется UMD-сборкой, поэтому её `require("vue")` разрешался в полную сборку Vue с рантайм-компилятором — около полумегабайта в bundle ради перетаскивания тегов в недоступной для редактирования форме. - **`pnpm audit` по всему графу: 33 предупреждения → 0.** Из них четыре затрагивали production-зависимости (`vue-i18n`, `echarts`), остальные — build-цепочку. Среди последних был rollup GHSA DOM clobbering, а он затрагивает **генерируемый bundle**, то есть уезжает в production: `pnpm audit --prod`, на который смотрит gate сборки, его не показывал. Обновлены: Vue 3.2 → 3.5, TypeScript 4.9 → 5.9, Element Plus 2.3 → 2.14, Vue Router 4.1 → 4.6, Pinia 2.0 → 2.3, VueUse 9 → 14, echarts 5 → 6, Vite 4.3 → 7.3, eslint 8 → 10 (с переходом на flat config), stylelint 15 → 17. Pinia 3, Vue Router 5 и Vite 8 сознательно не берутся: Vite 8 — это переезд на Rolldown, остальные не дают проекту ничего, кроме номера версии. - **`vite-plugin-svg-icons` заменён собственным спрайтом.** Плагин не обновлялся с 2022 года и тянул `svgo 2.8`, `postcss 5.2.18` и `image-size 0.5.5`, у которой advisory сообщает `Patched versions: <0.0.0` — исправленной версии не существует. Проверка на реальных ассетах поймала то, что иначе уехало бы в релиз: три иконки из семнадцати не объявляют `viewBox`, и без его синтеза из `width`/`height` отрисовывались бы обрезанными. - **Разбор bundle через sourcemap** нашёл вторую потерю: `@vueuse/core` собирался дважды — наш и тот, что тянет Element Plus. Версии сведены. Итог по размеру: 2 819 722 байта против 2 404 202 на исходной базовой линии. Рост в 17% — цена Vue 3.5, Element Plus 2.14, echarts 6 и rollup 4; промежуточное состояние до двух исправлений выше было 3 046 172. ### Исправлено — сборка не собиралась - **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.** `verify_api_namespace_contract` искал возвращение legacy-пространства имён через `grep -rlF '/hui'` по списку каталогов, в который входили `tools/build` и тесты. Поиск находил два файла: `apps/router/router_test.go`, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать отсутствие маршрута, и сам `versions.sh`, где эта строка стоит в тексте проверки. То есть добавление теста, закрепляющего очистку, ломало сборку, а до резолва Hysteria дело не доходило вовсе. Скан теперь идёт только по runtime production sources и по тому, что уезжает в пакет, с исключением `*_test.go`. Гарантия не ослабла, а переехала на слой, где она сильнее: отсутствие маршрута доказывает `TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера, и существование этого теста само стало частью контракта. - **Второй такой же контракт прятался за первым.** Проверка «импорт пиров не выходит за транзакцию» брала `source.slice(start)` — файл от начала `applyPeerImportEntry` и до конца, — захватывая объявленные ниже `ExistPeerName` и `UpdatePeerLastConnectionAt`. Это обычные операции вне импорта, которым глобальное соединение положено, поэтому проверка падала на корректном коде. Замечена не была только потому, что сборка до неё не доходила. Границей тела функции теперь служит следующее объявление верхнего уровня. Отсюда общее правило и помощники `code_without_comments` / `code_mentions_in` в `acceptance.sh`: приёмка проверяет **код**, а не упоминания. ### Исправлено — runtime - **Смена расписания сброса трафика размножала планировщики.** `middleware.InitCron()` вызывался из `runServer` и на каждом вызове создавал новый `cron.New()`, нигде не сохраняя ссылку; `cron.Stop()` не вызывался нигде. При этом смена `RESET_TRAFFIC_CRON` выполняла `StopServer()`, а точка входа крутила `for { runServer() }` и поднимала сервис заново. Каждая правка добавляла **целый дублирующий набор джоб** — учёт трафика, сбор метрик, уборка статистики, — а старое расписание сброса продолжало работать. После двух правок на процессе висели три планировщика и три разных расписания одновременно. Плюс окно, в котором джобы старого планировщика били в уже закрытое SQLite-соединение: `releaseResource()` отрабатывал раньше, чем следующий `runServer` успевал открыть базу. Планировщик теперь принадлежит процессу: фиксированные джобы регистрируются один раз, расписание сброса переносится на месте по своему `EntryID`, HTTP-сервер к смене настройки отношения не имеет. Цикл перезапуска в точке входа удалён — перезапуском упавшего юнита занимается systemd. - **Невалидное cron-выражение принималось API и молча отключало сброс трафика.** Поле в панели — `el-select` с `allow-create`, то есть строка произвольная; backend принимал её как строку до 128 символов, а ошибка `AddFunc` при следующем старте только логировалась. Оператор получал успех, панель работала, автоматический сброс исчезал. Выражение проверяется до записи в базу тем же парсером (`cron.ParseStandard`), которым его потом разбирает планировщик. Невалидное значение — отказ, база не меняется. Пустое значение легально и означает «сброс выключен». - **`updateConfigs` применял партию частично.** Валидация и запись шли в одном цикле, поэтому партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на втором. Существовавший тест ставил запрещённый ключ первым и не смотрел в базу — поймать это он был неспособен по построению. Теперь: полная проверка партии → одна транзакция (`dao.WithConfigTx`) → применение к рантайму. Тест переписан на обратный порядок ключей и проверяет состояние базы на настоящей SQLite. - **Сервис не завершался штатно.** `SIGTERM` от systemd убивал процесс на середине: соединения обрывались, SQLite закрывался вместе с процессом, джобы могли быть остановлены посреди записи. Добавлено штатное завершение — планировщик глушится и дожидается запущенных джоб, затем закрывается база. - **Ложные ERROR в журнале на каждой первой загрузке.** Создание секретов шло по схеме «сначала INSERT, при ошибке UPDATE», а строки ключей уже существовали из `seedBaseConfig`: три записи `duplicated key not allowed` уровня error на совершенно здоровом старте. Зеркальная схема «сначала UPDATE, при ошибке INSERT» в других местах была хуже — она **тихо не делала ничего**, если строки не было: UPDATE без совпавших строк не ошибка, поэтому ветка INSERT не выполнялась, а вызывающий получал сгенерированный секрет как сохранённый. Для `JWT_SECRET` это означало бы подпись токенов ключом, которого нет в базе. Обе схемы заменены на `dao.UpsertConfigValue`, решающий по `RowsAffected`. - **Дублирующая реализация генерации ключей шифрования.** В `service/peer_secret.go` лежали построчные копии `getOrCreateConfigKey` и `getPeerSecretEncryptionKey` из `dao/sqlite.go`: две функции в двух пакетах, порождающие один и тот же материал шифрования. Расхождение между ними означало бы, что секреты пиров шифруются одним ключом, а расшифровываются другим. Осталась одна реализация в `dao`. ### Безопасность - **Bootstrap-пароль администратора писался в журнал открытым текстом.** При отсутствии `HY2XS_ADMIN_INITIAL_PASSWORD` админка генерировала пароль сама и печатала его двумя `logrus.Warnf` в `/var/log/hy2xs/hy2xs-admin.log` — файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Такой пароль к тому же не знал никто, кроме журнала. Отсутствие переменной теперь **отказ старта** с объяснением причины. То же для `HY2XS_ADMIN_CON_PASS` при создании пира установщика: его секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`, откуда его читает проверка machine-auth, и придуманный админкой секрет разошёлся бы с файлом. - **Собственный журнал админки выгружался без санитайза**, хотя чужой (журнал Hysteria) — с санитайзом. Теперь оба проходят `SanitizeLogText`, и во вкладке просмотра тоже. - **`golang-jwt/jwt` v3 в пути аутентификации.** У v3.2.2 есть GO-2025-3553, у которой **нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый код достигается из `ParseToken`, то есть с неаутентифицированного запроса. Выполнен переход на `jwt/v5`. Заодно закрыт тихий недостаток: `keyfunc` возвращал ключ, не проверяя алгоритм подписи, — набор допустимых алгоритмов фактически задавал сам токен. Разбор ограничен `jwt.WithValidMethods`, проверяются `issuer` и обязательное наличие срока жизни; пустой `JWT_SECRET` считается повреждённым состоянием, а не ключом нулевой длины. - **Вход по несолёному SHA-224 больше невозможен.** `VerifyPassword` принимала такой хеш как «legacy»-формат предыдущего поколения. В v1 он недостижим: миграции таблицы `account` удалены, установка возможна только на чистый хост, конфигурация 0.x отклоняется по схеме. Compatibility-ветка пережила слой совместимости, ради которого существовала, и осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина. - **Modulo bias в генераторе секретов.** `util.RandomString` брала остаток байта от деления на длину алфавита (62): первые восемь символов выпадали примерно на четверть чаще остальных. Через эту функцию проходят `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY`, секрет trafficStats API, секреты и `auth_id` пиров. Добавлена отбраковка (rejection sampling). - **Пир установщика был защищён только в импорте.** Обычный CRUD панели позволял переподписать или переименовать `bootstrap-admin-peer`, молча рассинхронизировав базу с `/etc/hy2xs/bootstrap-admin.secret`. Защита распространена на все пути записи; удаление и отключение остаются разрешёнными — это осознанные действия оператора, не создающие расхождения. - **Латентная паника в разборе токена.** `service.GetToken` доставала токен через `strings.SplitN(header, " ", 2)[1]` и падала на заголовке без пробела. Единственный потребитель — резервная ветка `GetAdminInfo`, недостижимая и проверявшая меньше, чем middleware (ни статус учётной записи, ни версию токена). Оба удалены: разбор токена у продукта ровно один. - **`reset-admin` генерировал 6-символьные логин и пароль** — нижняя граница, которую пропускает `HashPassword`. Увеличено до 12 и 24. ### Изменено - **Toolchain переведён на поддерживаемые линии.** `GO_VERSION` 1.21.13 → 1.26.7, `NODE_VERSION` 20.19.0 (EOL) → 24.20.0. Go компилирует `hy2xs-admin`, поэтому его stdlib целиком попадает в production-бинарь: на прежнем графе `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в stdlib. После перехода и обновления зависимостей — **ноль**. Bun намеренно оставлен на 1.3.13: оркестратор собирается через `bun build --compile`, то есть Bun runtime входит в исполняемый файл, и смена его версии требует отдельного прохода по всей матрице проверок. - **Добавлен обязательный шаг проверки зависимостей** (`tools/build/lib/security.sh`): `govulncheck ./...` для Go-графа и stdlib с анализом достижимости и `pnpm audit --prod` для frontend. Версия `govulncheck` пиньтся в `versions.env`, база уязвимостей подтягивается на каждом запуске. Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`; результат уезжает в metadata пакета полем `dependency_security_gate`. - **Обновлены зависимости frontend, попадающие в браузерный бандл**: `axios` 1.3.4 → 1.20.0, плюс `lodash`/`lodash-es` через `pnpm.overrides` до 4.18.1. Прямые зависимости и их диапазоны не менялись — двинулся только lockfile. В production-графе не осталось уязвимостей уровня high и critical. ### Удалено - **Четыре ключа таблицы `config` без единого потребителя** — `HYSTERIA2_ENABLE` (жизненным циклом Hysteria владеет systemd), `HYSTERIA2_CONFIG` (второй источник истины рядом с `/etc/hysteria/config.yaml`, причём читался **первым**), `HYSTERIA2_TRAFFIC_TIME` (настройка «период учёта трафика», которую не читал никто: интервал сбора метрик задан в коде) и `HYSTERIA2_CONFIG_REMARK` (пустая read-only строка). Строки удаляются миграцией `006_drop_dead_config_keys`. Настоящую замену получил только последний: имя профиля в клиентской ссылке теперь выводится из имени пира, а при его отсутствии — из публичного хоста. После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`. ### Исправлено — предыдущий проход - **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install` `preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста. Ко второму вызову на диске уже лежал собственный `/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он опознавался как маркер посторонней установки. Отказ приходил уже как `fatal_post_apply`: сервер оставался наполовину настроенным, а повторный запуск упирался в тот же маркер. Причина в том, что clean-host и проверка возможностей платформы ехали одним параметром, хотя отвечают на разные вопросы: чистота хоста — условие **входа** в операцию, а `systemd-run`/`nftables`/OpenSSL 3 проверяются уже после `installDeps`, то есть внутри PHASE 1. `preflight()` теперь принимает `checkCleanHost` явно и **без значения по умолчанию** в режиме install: любое умолчание здесь неверно, решение обязано приниматься на месте вызова. - **PHASE 1 начиналась вне зоны ответственности оркестратора.** `install.sh` сам создавал `/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink в `/usr/local/bin` и копировал runtime-пакет — и только потом запускал оркестратор, у которого дальше шёл собственный preflight. Если тот отказывал (сменился DNS, занялся порт, не ответил резолвер), ни один ownership-флаг не был взведён: отказ классифицировался как `fatal_pre_apply`, и оператор читал «на сервере ничего не изменено» при уже созданном каталоге оркестратора. Следующий запуск упирался в эти пути как в маркеры чужой установки. Отследить владение мутацией невозможно, пока мутируют двое. Теперь `install.sh` не изменяет на сервере **ничего**: он проверяет и передаёт управление через `exec`. Раскладку выполняет сам оркестратор — шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, а сами пути попадают в `owned_paths` install-state наравне с остальными. Сборка проверяет структурно, что в установщике не осталось ни одной мутирующей команды. Побочный эффект: у списка clean-host маркеров больше нет «мягкой» версии для PHASE 1. Она существовала только затем, чтобы установка не отказала на путях, которые shell создал между фазами. - **Machine token утекал в обычные логи при каждом подключении пира.** Hysteria обращается к машинному endpoint'у как `/internal/hysteria/auth?access_token=<секрет>`, а журнал админки писал `c.Request.RequestURI` — то есть путь вместе с query string. Действующий токен оседал открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, который отдаётся оператору через `ExportLog` и попадает в diagnostics-бандл. Вся структурная редакция, сделанная для конфигов и env, этот канал не закрывала. Логируется путь; значения query-параметров не пишутся вовсе, имена — пишутся (`reqQueryKeys`). Поле `reqUri` удалено из модели журнала. Каналов было два: `gin.Default()` подключает `gin.Logger()`, который печатает путь вместе с query в stdout, откуда он уходит в journald, а оттуда — в diagnostics-бандл. Панель запускается через `gin.New()` + `gin.Recovery()`, и HTTP-логгер у продукта остался ровно один. Дополнительно: журналы внутри diagnostics-бандла (`journal-admin.log`, `journal-hysteria.log`, вывод `systemctl status`) больше не копируются как есть, а проходят санитайз; тот же проход применяется к журналу Hysteria, который админка отдаёт через `ExportLog`. Сравнение machine token переведено на `subtle.ConstantTimeCompare`. - **Config API позволял прочитать и подменить криптографические ключи приложения.** Generic export/import таблицы `config` удалили, но точечный API остался прежним: `getConfig`/`listConfig` принимали произвольный ключ, а проверка записи работала denylist'ом из трёх ключей оркестратора. Запрос `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов пиров, а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа. Доступ переведён на **allowlist**: наружу открыты только `HYSTERIA2_TRAFFIC_TIME`, `RESET_TRAFFIC_CRON` (чтение и запись) и `HYSTERIA2_CONFIG_REMARK` (только чтение). Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл; при allowlist забытый ключ закрыт. Маршрут `GET /api/config/getConfig` удалён целиком — потребителей у него не было ни одного, а фильтр на неиспользуемой двери остаётся дверью. - **Импорт пиров не был атомарным, вопреки собственному контракту.** Партия проверялась целиком до первой записи, но применялась по одной записи, каждая своим оператором. Валидация ничего не знает о том, что уже лежит в базе: пусть есть `A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а файл несёт `(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и попытается переименовать её в `bob123`, прямо в `UNIQUE(name)`. Всё, что шло в файле до конфликтной строки, оставалось применённым, и откатить это оператор уже не мог. Применение выполняется одной транзакцией (`dao.WithPeerTx`). Криптоматериал считается до её открытия: digest и шифрование читают ключи из той же таблицы `config`, и держать на ней открытую запись во время AES по каждой из тысяч записей незачем. - **Файл импорта мог содержать хвост, который молча не применялся.** `json.Decoder` читает первый документ и останавливается, поэтому файл вида `[{...}]\n{"что-то":"ещё"}` принимался целиком: оператор видел «импорт выполнен» и не узнавал, что применилась половина. После разбора проверяется `io.EOF`. - **Отказ сбора диагностики отменял откат.** В `install` и `reconfigure` `diagnosticsCollect()` стояла перед rollback обычным `await`. Она создаёт каталог, копирует файлы и упаковывает tar — на заполненном диске падает сама, и тогда худший сценарий отказа установки гарантированно лишался единственного механизма восстановления. Диагностика — best effort, откат — обязателен. - **`fatal_pre_apply` мог означать «хост уже изменён».** `install-state.json` пишется сразу после успешного preflight, до установки пакетов, но классификация отказа его не учитывала. Падение `apt-get update` или `apt-get install` объявлялось как «на сервере ничего не изменено»: откат и обработка состояния пропускались, а маркер оставался на диске и ломал следующую установку по clean-host контракту. Ownership-флаги переформулированы с «шаг успешно завершился» на «операция могла начать менять систему» и взводятся **перед** мутирующим вызовом: `apt-get` умеет изменить систему и упасть. `fatal_pre_apply` теперь недостижим ни при одном взведённом флаге, включая запись состояния. - **Экспорт в админке оставлял секреты на диске навсегда.** `ExportPeer` и выгрузка системного конфига шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там не удалялся. При `?includeSecrets=true` это означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде, накапливающиеся с каждым нажатием кнопки. Экспорт формируется в памяти; каталога `export/` больше нет. - **Generic export/import таблицы `config` выгружал и позволял подменить криптографические ключи приложения.** Выгрузка исключала только сырой Hysteria YAML, а в той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`. Импорт их не блокировал: подмена `PEER_SECRET_ENCRYPTION_KEY` ломает расшифровку секретов уже существующих пиров. Оба маршрута и их UI удалены — production-сценария у них не было, перенос пиров делают `peer-import`/`peer-export`. - **Импорт пиров шёл мимо всей валидации.** Обычное создание пира проходит через `dto.PeerSaveDto`, импорт JSON — нет: в базу попадало имя любой длины и с любыми символами, `disabled` с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список пиров наполовину изменённым, а импорт мог перезаписать `bootstrap-admin-peer`, чей секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`. Партия теперь проверяется целиком до первой записи, неизвестные поля отклоняются, bootstrap-пир защищён. - **DNS проверялся на существование A-записи, но не на то, куда она ведёт.** После принудительной смены IPv4 провайдером `doctor` отвечал успехом, хотя клиентская ссылка отправляла людей на чужую машину. Проверялся при этом `HY2XS_DOMAIN`, тогда как в `hysteria2://` уезжает `HY2XS_PUBLIC_HOST`. Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка живёт в общем `preflight`, поэтому действует в `install`, `reconfigure` и `doctor`. Адрес определяется локально, без внешних сервисов определения IP. Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY` (`strict` по умолчанию). - **Read-only guard PHASE 0 можно было обойти.** Guard стоял на `writeText`, `writeTextAtomic`, `runVisible`, `runHidden` и `runRawVisible`, но не на универсальном `run`, через который в коде проходили и наблюдение (`ss`, `systemctl is-active`), и настоящие мутации (`useradd`, `install -d`, `mkdir`, `cp -a`, `tar`). Универсального раннера больше нет: есть `runReadOnly*` без guard'а и `runMutating*` под guard'ом, а выбор — явное решение на месте вызова. - **Go-санитайзер конфига вырезал секреты из URL только у ключей `url`/`addr`.** Будущее upstream-поле с другим именем (`endpoint:`) уносило встроенные учётные данные и `access_token` наружу целиком; URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора; обе реализации покрыты зеркальными тестами. - **`purge-v0.sh --keep-hysteria-binary` противоречил установщику.** Скрипт сохранял `/usr/local/bin/hysteria` и сообщал «хост чист для установки HY2XS v1», хотя clean-host контракт считает этот бинарник legacy-маркером и следующая установка отказалась бы. Флаг удалён. - **clean-host не замечал часть того, что удаляет purge.** `/var/lib/hysteria` (ACME-состояние и сертификаты Hysteria), `/var/log/hy2xs`, `/usr/local/lib/hy2xs` и `/usr/local/bin/hy2xs-orchestrator` не были маркерами: сервер, где остался только старый runtime-state Hysteria, проходил проверку и получал свежую установку поверх чужого состояния. Оба списка теперь описывают одну границу, и приёмка это проверяет. - **Установщик мог повредить работающий сервер до того, как откажется его трогать.** `install.sh` переписывал `/usr/local/lib/hy2xs`, раскладывал runtime-пакет и перезаписывал `/var/lib/hy2xs/install-state.json`, и лишь потом запускал clean-host preflight. При ошибочном запуске поверх старого сервера rollback дополнительно выполнял `stop` и `disable` для работающих `hysteria-server` и `hy2xs-admin`. Установка разделена на две фазы с жёсткой границей: **PHASE 0 — read only**, **PHASE 1 — mutation**. Read-only проверка выполняется новой командой `hy2xs-orchestrator preflight-install` из распакованного архива, а граница держится runtime-guard'ом, а не соглашением. - **Отсутствие `HY2XS_CONFIG_SCHEMA_VERSION` считалось текущей схемой.** До v1 этого поля не существовало, поэтому именно пустое значение — самый вероятный признак конфигурации `0.x`. Теперь оно отклоняется как legacy с указанием на чистую установку. Тест, закреплявший прежнее поведение, инвертирован. - **`reconfigure` и `repair` работали поверх любого маркера установки.** Проверялся только флаг `installed`, который мог остаться и от `0.x`. Маркер получил идентификацию поколения (`product`, `release_line`, `config_schema_version`), и обе команды проверяют её до всего остального. - **Классификация отказа шла по тексту сообщения об ошибке.** Ошибка preflight со словом `nftables` классифицировалась как отказ firewall и приводила к откату чужого ruleset. Теперь классификация опирается на то, что операция реально успела применить. `systemctl stop/disable` выполняется только для юнитов, развёрнутых текущей операцией, а `fatal_pre_apply` по определению не выполняет системный откат и не собирает diagnostics-бандл. - **Diagnostics-бандл уносил machine token наружу.** Построчное правило редакции `auth:` подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенный `auth.http.url` с `access_token=<секрет>` — тем самым, что открывает и trafficStats API, и auth-endpoint. Редакция YAML переписана структурно; в env-файлах секреты теперь вырезаются и из URL-значений (`HY2_AUTH_URL` не подходил ни под один маркер имени). - **`quic.maxIdleTimeout` не проверялся** семантической проверкой конфига, хотя присутствовал в production-профиле. Заодно `auth.http.url` теперь сверяется целиком (host/port/path/token), а не по наличию подстроки `access_token=`; добавлены проверки `auth.http.insecure`, полей ACME и отсутствия посторонних секций верхнего уровня. - **Версия админки разъехалась с версией пакета**: пакет `1.0.0` сообщал `HY2XS admin version v0.0.22`. Константа заменена переменной, которую проставляет сборка через ldflags из `versions.env`. - **Кнопки в панели, которые всегда возвращали ошибку.** «Перезапустить панель» и загрузка сертификатов обращались к заглушкам. Маршруты и UI удалены. ### Добавлено - **`versions.env`** — единственный источник истины для контракта «продукт / платформа / toolchain»: версия продукта, линия релиза, схема конфигурации, целевая платформа, версии и контрольные суммы Go/Bun/Node/pnpm, политика выбора Hysteria. Прикладные зависимости и конкретная версия Hysteria сюда намеренно не переносятся: у них есть собственные lock-механизмы. - **Шаг сборки `verify_versions_contract`.** Роняет сборку до создания tarball, если разошлись `PACKAGE_VERSION`, `packageManager` в двух `package.json`, схема в `package/config/hy2xs.env`, константы, скомпилированные в оркестратор, директива `go` в `apps/go.mod`, metadata пакета или версия, которую сообщает собранный `hy2xs-admin`. - **Контрольные суммы toolchain в контракте**, включая **обе** сборки Bun (`bun-linux-x64` и `bun-linux-x64-baseline`): артефакт выбирается по наличию AVX2, поэтому одной суммы архитектурно недостаточно. Передавать суммы через окружение больше не нужно — production-сборка запускается одной командой. - **Проверка происхождения артефакта Hysteria.** Ожидаемый SHA-256 берётся из upstream-ассета `hashes.txt` и сверяется со скачанным бинарником до записи в HY2XS lock. Раньше сумма считалась локально от уже скачанного файла, то есть была trust-on-first-use. - **Полный clean-host контракт.** Список маркеров чужой установки расширен с двух до четырнадцати: состояние, runtime-пакет, конфиги, бинарник Hysteria, фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути установки и данных берутся из конфигурации, а не захардкожены. - **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — явная очистка сервера от предыдущего поколения. По умолчанию скрипт показывает план и ничего не делает; выполнение требует `--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло бы destructive migration logic в путь свежей установки. - **Явный флаг `--allow-partial-state` для `repair`.** Прежде согласие на работу поверх незавершённой установки подразумевалось молча. - **`HY2XS_PUBLIC_ENDPOINT_POLICY`** (`strict` | `warn` | `off`, по умолчанию `strict`) — строгость проверки того, что публичный endpoint ведёт на этот сервер. Ослабление предназначено для топологий вне baseline: NAT, floating IP, anycast. Отсутствие A-записи фатально при любом значении. - **Раздельные API подпроцессов в оркестраторе**: `runReadOnly` / `runReadOnlySecret` для наблюдения и `runMutating*` под read-only guard'ом. - **Сверка API namespace на сборке.** Путь machine-auth и базовый префикс админского API объявлены по одной константе на компонент, а `verify_versions_contract` сверяет Go, фронтенд и шаблоны против значений, скомпилированных в оркестратор. ### Изменено - **Пространства имён HTTP API.** Операторский и auth API переехали с `/hui` на `/api`, machine-auth endpoint Hysteria — на `/internal/hysteria/auth`. Прежний общий префикс был наследием H UI: под ним лежали и machine-to-machine auth, и JWT-защищённый админский API, хотя middleware у них не пересекаются. Момент выбран до первого clean-install релиза: после `1.0.0` эти строки стали бы частью фактического v1 compatibility contract. - **Сетевая идентичность админки принадлежит оркестратору.** Ключи `H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` удалены из схемы, seed и интерфейса вместе с собственным TLS-слоем панели. Раньше оркестратор передавал порт аргументом, админка записывала его в SQLite и тут же читала обратно, а UI показывал поля в disabled-виде: второй источник истины, из которого ничего нельзя было изменить. Панель всегда монтируется в `/`. - **`HUI_DATA`/`HUI_LOG` → `HY2XS_DATA_DIR`/`HY2XS_LOG_DIR`.** Мост в systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI, удалён. - **Экспорт пиров разделён на два явных режима.** «Экспорт настроек» — без секретов, «Резервная копия» — с ними, через подтверждение с описанием риска. Кнопка была одна и всегда звала маршрут без `includeSecrets`, хотя документация называла эту пару механизмом переноса пиров. Записи с пустым секретом при импорте получают **новые** секреты, поэтому перенос обычным экспортом восстанавливал пиров, но все существующие клиентские ссылки после него переставали работать. Разница продуктовая, и оставлять её неявной нельзя. - **`reconfigure`/`repair` больше не классифицируют отказ по тексту ошибки.** Записываемая фаза выбиралась регулярным выражением `/firewall|nft|ssh port check failed/i` по сообщению — тот же приём, который уже убрали из `install`. Классификация переведена на ownership-флаги, а откат firewall выполняется только если эта операция его трогала. - **Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry.** Функция называлась «маршрутизируемый публичный IPv4», а исключения покрывали только приватные диапазоны: `203.0.113.5` (TEST-NET-3 из RFC-примеров) считался нормальным публичным адресом сервера. Добавлены документационные (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`), benchmarking (`198.18/15`), 6to4-anycast и IETF protocol assignments. - **Отказ DNS-резолвера отличается от отсутствия A-записи.** Любая ошибка `resolve4` печаталась как «has no A-record», поэтому при сломанном `/etc/resolv.conf` оператор шёл править запись, которая была на месте. `ENODATA`/`ENOTFOUND`/`NXDOMAIN` — это «нет записи», всё остальное — «резолвер не ответил», с отдельным текстом. Фатальны оба: без ответа резолвера проверка не выполнена, а не «выполнена с замечанием». - **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема — `apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого артефакта, который очистка должна найти. - **Индикатор загрузки и legacy-цвета переведены на брендовый токен.** NProgress приходил со своим `#29d` и был единственным элементом интерфейса вне палитры HY2XS; страницы `401`/`404` и подсветка выбранной строки таблицы несли цвета исходного admin-шаблона. Все они привязаны к `--el-color-primary`, а не переписаны вторым литералом. - **Приёмка сверяет схему конфигурации с `versions.env`**, а не с числом `2` в тексте проверки. - **E2E подключается по ссылке из production-кода.** Внутри `tools/test/e2e-hysteria.sh` жила вторая реализация `hysteria2://` URI на bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными. Теперь ссылку выдаёт `service.BuildHysteria2ShareURI` через `apps/tools/share-uri`. Единственное расхождение — `insecure=1` для самоподписанного сертификата, и оно ограничено тестами с двух сторон. - **Формулировка гарантии санитайза экспорта.** Вместо «любой будущий секрет будет удалён» — «известные секреты и неизвестные поля с секретоподобным именем». Список маркеров расширен (`apiKey`, `privateKey`, `authorization`, `cookie`, `bearer`, `passphrase`, `signature`, …) и синхронизирован между Go-админкой и оркестратором. ### Удалено - Маршруты, операциями которых продукт не владеет: `POST /hysteria2ChangeVersion`, `GET /listRelease`, `POST /config/updateHysteria2Config`, `POST /config/importHysteria2Config`, `POST /config/restartServer`, `POST /config/uploadCertFile`, `GET /config/hysteria2AcmePath`. Вместе с ними — соответствующие сервисы, клиентские функции фронтенда, кнопки и строки i18n. Маршруты удалены, а не оставлены отвечающими «feature disabled»: API-контракт не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут остаётся attack surface. - `POST /config/exportConfig` и `POST /config/importConfig` — generic-выгрузка и загрузка таблицы `config` вместе с криптографическими ключами приложения. Вместе с ними — кнопки Import/Export в настройках, клиентские функции и строки i18n. - Персистентный каталог выгрузок `/var/lib/hy2xs-admin/export/` и файловый helper `util.ExportFile`. Артефакт, который покидает сервер, не должен существовать на сервере дольше самого запроса. - Флаг `purge-v0.sh --keep-hysteria-binary`. - Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`, `hui*`, `useHysteria2Cert`, `invalidWebContext`, `mustBeInteger`. - `GET /api/config/getConfig` — точечное чтение произвольного ключа таблицы `config`. Потребителей у маршрута не было ни одного, а список ключей в этой таблице включает `JWT_SECRET`, `PEER_SECRET_KEY` и `PEER_SECRET_ENCRYPTION_KEY`. Вместе с ним удалены `dto.ConfigDto`, клиентская функция `getConfigApi` и её тип. - Compatibility-слой аккаунтов предыдущего поколения: сущность `entity.LegacyAccount`, миграции `002_migrate_legacy_accounts` и `003_archive_legacy_account`, а также мёртвые helpers `listSQLMigrationFiles` и `envInt`. HY2XS v1 не мигрирует базу `0.x` ни при каком сценарии, и clean-host контракт отказывает ещё до создания базы — живого пути, по которому таблица `account` могла бы оказаться в `hy2xs-admin.db`, не существует. Номера оставшихся миграций сохранены: перенумерация заставила бы их примениться повторно. В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они обозначают реальные объекты, которые нужно удалить с сервера. Из остальных v1-доков этот словарь убран. ## [1.0.0] — 2026-08-27 Первый релиз линейки `v1`. > **Обновление с `0.x` не поддерживается.** > Между `0.x` и `1.0.0` изменились схема конфигурации, тип обфускации > по умолчанию и контракт выбора версии Hysteria. Сервер, установленный > из пакета `0.x`, нужно поднимать заново: очистка и установка с нуля. > Подробности — в разделе «Миграция с 0.x» ниже. ### Добавлено - **Разрешение версии Hysteria на этапе сборки.** Builder по умолчанию сам определяет последний стабильный upstream-релиз, скачивает артефакт, вычисляет SHA-256 и замораживает `version` + `url` + `sha256` в metadata пакета. Target-сервер по-прежнему скачивает конкретный неизменяемый артефакт и никогда не обращается к moving `latest`. - **Compatibility gate в сборке.** До создания release-пакета builder рендерит канонический конфиг HY2XS тем же кодом, что и оркестратор, и запускает с ним реальный бинарник Hysteria — для обоих профилей обфускации. Несовместимый upstream ломает сборку, а не сервер оператора. - **Поддержка Gecko-обфускации** (Hysteria 2.9.2+) со сквозной интеграцией: оркестратор, шаблон конфига, модель админки, генерация `hysteria2://` URI, типы и формы фронтенда. - **Версия схемы конфигурации** `HY2XS_CONFIG_SCHEMA_VERSION=2`. Пакет отказывается работать с конфигурацией неизвестной схемы вместо того, чтобы молча применить чужие значения. - **Современный серверный baseline** в генерируемом конфиге: `congestion.type: bbr` + `bbrProfile: standard`, `bandwidth.disableLossCompensation: false`, `quic.disableStatelessReset: false`, а также `maxIdleTimeout`, `maxIncomingStreams`, `disablePathMTUDiscovery`. - **Модель современной схемы Hysteria в админке**: `obfs.gecko`, `ech`, `congestion`, `mimic`, `realm`, `tls.clientCA`, `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`, `masquerade.proxy.xForwarded`. Поля читаются и отображаются, даже если HY2XS не включает их в default-профиль. - **Тесты оркестратора** (`bun test`): разбор env, рендер конфига, семантические инварианты профиля, резолвер upstream-релизов, release rollover. - **E2E-проверка с реальным клиентом Hysteria** — `tools/test/e2e-hysteria.sh`: TLS и obfs handshake, HTTP auth (допуск и отказ), TCP и UDP forwarding, trafficStats, per-peer accounting, переподключение после перезапуска сервера и подключение клиента именно по сгенерированной ссылке. - **`CHANGELOG.md`** в корне репозитория. ### Изменено - **Обфускация по умолчанию для новых установок — Gecko.** Salamander остаётся полностью поддержанным режимом совместимости и выбирается через `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. - **Gecko использует upstream-defaults `512/1200`** и не выносит размеры пакетов в env: официальная URI-схема не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной. - **Тип обфускации больше не собирается внутри статического YAML.** Оркестратор формирует проверенный `obfs`-блок целиком, поэтому комбинация вида `type: gecko` рядом с блоком `salamander` структурно невозможна. - **Экспорт конфига Hysteria работает от исходного YAML**, а не от типизированной модели: поля, о которых HY2XS ещё не знает, переживают выгрузку. - **Smoke-проверки разбирают YAML** и сверяют его с production-профилем, вместо поиска подстрок. - **Канонический upstream-репозиторий** — `HyNetworks/hysteria` (вместо устаревших ссылок на `apernet`). - **Реестр ACME DNS-провайдеров во фронтенде** приведён к актуальному upstream: добавлены `namecheap`, `njalla`, `porkbun`. - **Дефолты формы Hysteria во фронтенде** отражают baseline HY2XS (50/50 Mbps, Let's Encrypt, каталог ACME), а не пример из upstream-доки. - Сборка запускает тесты оркестратора и админки до упаковки. ### Исправлено - **`hysteria2://` для Gecko.** Генератор ссылок был завязан на `Obfs.Salamander.Password`, поэтому при любой другой обфускации выдавал формально корректную, но неработающую ссылку без параметров `obfs`. - **SNI в клиентской ссылке при файловых сертификатах.** SNI брался только из ACME-блока, поэтому при `HY2XS_TLS_MODE=file` уходил пустым. Теперь источник — ACME-домен, затем `HY2XS_DOMAIN`, затем `HY2XS_PUBLIC_HOST`; IP-адрес в качестве SNI не используется. - **Утечка секретов в экспорте конфига.** Выгружаемый оператору YAML содержал `trafficStats.secret`, `access_token` в auth-URL и пароль обфускации. Секреты вырезаются, включая поля, о которых HY2XS ещё не знает. - **Расхождение runtime-конфига с разобранным.** `renderRuntimeEnv` печатал тип обфускации и режим auth литералами, игнорируя фактическую конфигурацию, — из-за чего запись `/etc/hy2xs/hy2xs.env` могла разойтись с тем, что реально применено. - **Смешение веток `obfs` в UI.** Форма склеивала дефолт с ответом API и показывала блок обфускации, которого нет в конфиге сервера. - **`namedotcom` в списке ACME DNS-провайдеров.** Провайдер удалён из Hysteria в 2.11.0; конфигурация с ним больше не запускается. - **Отсутствие подсказок в форме создания пира.** У полей «Пир», «Комментарий» и «Секрет» не было ни примеров, ни пояснений: оператор не мог понять без документации, что секрет необязателен и генерируется автоматически. ### Безопасность - Переход на Hysteria 2.12.2 закрывает исправления, вышедшие после 2.8.2, включая обход UDP ACL, возможный OOM через sniff и обход ACL через домены с завершающей точкой (2.9.2). - Экспорт конфига больше не выносит секреты за пределы сервера. ### Миграция с 0.x Автоматическая миграция не предусмотрена и не планируется. Порядок перехода: 1. Выпишите с работающего сервера список пиров и их секреты. 2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md). 3. Разверните `1.0.0` на чистом Debian 13 из release-пакета. 4. Заведите пиров заново и раздайте новые клиентские ссылки. Установщик `1.0.0` обнаружит остатки предыдущей установки на шаге PHASE 0, откажется работать и **не изменит на сервере ничего**. Клиентские ссылки `0.x` в любом случае перестанут работать: смена обфускации — это изменение wire-совместимости. [Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/1.0.0-rc5...main [1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/1.0.0-rc5