65042ee335
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2187 lines
198 KiB
Markdown
2187 lines
198 KiB
Markdown
# Изменения HY2XS
|
||
|
||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||
Проект использует [семантическое версионирование](https://semver.org/lang/ru/).
|
||
|
||
Версия относится к самому HY2XS, а не к Hysteria: версия Hysteria выбирается
|
||
на этапе сборки пакета и фиксируется в его metadata.
|
||
|
||
## [Unreleased]
|
||
|
||
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.
|
||
|
||
### Исправлено — вход в панель
|
||
|
||
- **`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` сразу после успешной проверки пароля; отказ записи
|
||
вход не отменяет, но попадает в журнал.
|
||
|
||
### Исправлено — контракт пароля администратора
|
||
|
||
- **Граница пароля не совпадала с границей 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:<port>`: такой адрес выглядел
|
||
локальным, ломал контур доступа целиком, и админка об этом молчала.
|
||
Принимаются ровно `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: <nil>` именно при отказе 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"` прямо на `<path>`, а
|
||
атрибут представления перебивает унаследованное CSS-свойство. Под это
|
||
попадали все семь иконок бокового меню на фоне `#181818`.
|
||
|
||
Литеральный цвет убран из ассетов; многоцветные объявлены явным списком;
|
||
преобразование в `<symbol>` и контракт ассета вынесены в чистый модуль
|
||
`SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит,
|
||
проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация
|
||
скрывала бы ровно тот дефект, который контракт обязан делать видимым.
|
||
|
||
- **У `SvgIcon` убран проп цвета** и атрибут `fill` на `<use>`: он приглашал
|
||
чинить отрисовку точечно в обход общего контракта.
|
||
|
||
### Исправлено — правила имени пира
|
||
|
||
- **Два правила на одном поле противоречили друг другу.** Стояли
|
||
`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 <unit>.timer <unit>.service || true
|
||
-> "firewall rollback timer disarmed"
|
||
-> phase=installed
|
||
```
|
||
|
||
Между «мы думаем, что guard снят» и «guard действительно снят» не было ни
|
||
одной проверки: `|| true` стирал код возврата, и взведённый таймер мог
|
||
вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать
|
||
`|| true` было нельзя — для транзиентного юнита, уже убранного systemd,
|
||
`systemctl stop` возвращает 5, и этот исход неотличим от успеха.
|
||
|
||
Введён маркер `/run/hy2xs/rollback/<op-id>/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/<op_id>`, который 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/<op-id>/` с манифестом, где
|
||
отсутствие файла — записанный факт (`"present": false`), а не вывод из
|
||
неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`.
|
||
|
||
- **Ошибка восстановления скрывалась, а копии после неё удалялись.**
|
||
`rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем
|
||
безусловно удаляла `/run/hy2xs/rollback/<op>`. Худшая возможная комбинация:
|
||
неудача восстановления не видна, стадия отчитывается успешной, а данные, по
|
||
которым оператор мог бы поднять 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/v1.0.0...HEAD
|
||
[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/v1.0.0
|