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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user