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
+205 -1
View File
@@ -1,6 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# code_without_comments отдаёт содержимое файла без строк-комментариев.
#
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
# документации, которая описывает выполненную ею же работу.
#
# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
# третьего раза не было.
code_without_comments() {
grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
shift
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_without_comments "$file" | grep -qF -- "$needle"; then
hits="${hits}${hits:+ }${file}"
fi
done <<EOF
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
EOF
printf '%s' "$hits"
}
run_fix20_acceptance_subset() {
local package_dir="$1"
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
@@ -371,6 +404,165 @@ run_clean_install_acceptance() {
run_secret_channel_acceptance
run_atomic_import_acceptance
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
[ -f apps/service/cron_scheduler.go ] \
|| fail "acceptance: модуль планировщика отсутствует"
[ ! -f apps/middleware/cron.go ] \
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
# Именно этот путь плодил планировщики — StopServer возвращал управление в
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
# внутрипроцессного перезапуска.
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
const release = source.indexOf("defer releaseResource()");
const stopCron = source.indexOf("defer service.StopCron()");
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
// defer выполняются в обратном порядке регистрации: releaseResource должен
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
grep -q 'ValidateConfigValue' apps/controller/config.go \
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
grep -q 'func WithConfigTx' apps/dao/config.go \
|| fail "acceptance: у записи настроек нет транзакционной границы"
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
# Проверка обязана завершиться ДО первой записи.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
const validate = source.indexOf("service.ValidateConfigValue(");
const apply = source.indexOf("service.UpdateConfigs(updates)");
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
}
# Мёртвые ключи таблицы `config` не должны вернуться.
run_dead_config_acceptance() {
log_step "Acceptance: dead config keys stay removed"
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
# другое обязано называть их по имени.
local dead_key hits
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
hits="$(code_mentions_in "$dead_key" \
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
[ -z "$hits" ] \
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
done
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
# была вторым источником истины и читалась ПЕРВОЙ.
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
log_step "Acceptance: share URI remark is derived, not stored as dead state"
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
}
# Гигиена зависимостей и bootstrap-учётных данных.
run_dependency_hygiene_acceptance() {
local package_dir="$1"
log_step "Acceptance: bootstrap credentials are never invented or logged"
# Ключевой канал утечки: сгенерированный пароль печатался в
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
grep -q 'func ExportAdminLog' apps/service/journal.go \
|| fail "acceptance: журнал админки выгружается без санитайза"
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
local jwt_v3_hits
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
[ -z "$jwt_v3_hits" ] \
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
log_step "Acceptance: password storage has exactly one format"
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
log_step "Acceptance: the release is built with a dependency vulnerability gate"
[ -f tools/build/lib/security.sh ] \
|| fail "acceptance: модуль проверки зависимостей отсутствует"
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|| fail "acceptance: сборка не запускает проверку зависимостей"
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
# По готовому tarball должно быть видно, проверялся он или собран с
# пропущенной проверкой — ровно как hysteria_compat_gate.
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
log_step "Acceptance: the Go toolchain is on a supported line"
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
local go_minor
go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)"
[ "$go_minor" -ge 26 ] \
|| fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib"
}
# PHASE 1 принадлежит оркестратору целиком.
@@ -537,11 +729,23 @@ run_atomic_import_acceptance() {
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|| fail "acceptance: WithPeerTx must open a real database transaction"
# Применение обязано идти по tx, а не по глобальному соединению.
#
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
# которым глобальное соединение положено. Проверка гарантированно падала на
# корректном коде и не была замечена только потому, что сборка до неё не
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
#
# Границей тела функции служит следующее объявление верхнего уровня.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const start = source.indexOf("func applyPeerImportEntry");
if (start < 0) throw new Error("applyPeerImportEntry is missing");
const body = source.slice(start);
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
}