fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки

Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
This commit is contained in:
2026-08-29 21:37:38 +05:00
parent 672d455467
commit b99be7d514
50 changed files with 3907 additions and 1622 deletions
+191 -1
View File
@@ -12,7 +12,197 @@ Hardening-проход перед релизом `1.0.0`. Основная те
«только чистая установка» настоящим системным инвариантом, а не строчкой в
документации.
### Исправлено
Второй проход закрывает то, что осталось: два контракта приёмки, гарантированно
ронявшие сборку на корректном коде; жизненный цикл планировщика; мёртвое
состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст
графа зависимостей.
### Исправлено — сборка не собиралась
- **`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()` вызывался дважды, и оба раза проверял контракт чистого хоста.