Files
HY2XS_flamy/tools/build/lib/acceptance.sh
T
Crimson ab788725cf fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

1. Домен значений файла окружения

Перед тем как принять пару, systemd прогоняет ключ и значение через
utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там
возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не
стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает
суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам
utf8_is_valid — встроенный NUL и невалидный UTF-8.

Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего —
проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и
после этого админка не поднималась. Тот же класс дефекта, ради уничтожения
которого контракт и существует, только слоем ниже.

Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий
множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные
суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет
непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ
секрет, а не отказ.

Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про noncharacters.
Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на
C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом:
0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает.

2. Рецепт восстановления выполнял env-файл как код

В docs/operations/12, раздел «Забыт пароль администратора», стояло
`set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда,
когда файл научился нести произвольные значения. Для systemd
HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в
EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри
двойных кавычек выполняет подстановку команд — от root, прямо в рецепте
восстановления доступа. Соседний раздел той же страницы при этом уже правильно
запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и
тут же его предлагал.

Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий
возврат source/./eval над этими файлами в командах документации и в скриптах;
гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную
конструкцию по имени, его не роняло.

3. Отказ приходил после мутаций хоста

Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов
и раскладки файловой системы, — а read-only preflight-install говорил PASS: он
зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка
конфигурации роняла операцию, оставив за собой изменённый хост, что прямо
противоречит контракту PHASE 0.

validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по
ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю
пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же.

4. Точность порта автомата и его описания

- в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не
  проверку на любой перевод строки (в VALUE_ESCAPE — наоборот,
  strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>;
- комментарий обещал одно намеренное расхождение с systemd, а их два: кроме
  строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла.
  Оба fail-closed и теперь названы оба.

Тесты: граничная таблица во всех слоях дополнена значениями вне домена
(U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8),
соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным
суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не
уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика
различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает
непригодную конфигурацию, проверяются все значения файла, запись и проверка
ходят по одному списку пар.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:38:57 +05:00

2979 lines
214 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# code_without_comments отдаёт содержимое файла без строк-комментариев.
#
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
# документации, которая описывает выполненную ею же работу.
#
# Урок в этом файле оплачен уже пять раз: скан versions contract ловил сам себя
# на строке '/hui'; dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие; скан иконок падал на
# блочном комментарии, объясняющем замену плагина; скан прежних имён раннеров —
# на слове `systemd-run` в прозе; скан `cancelFirewallRollback` — на
# комментарии, описывающем её разделение.
#
# Отсюда правило для КАЖДОЙ отрицательной проверки по дереву исходников:
#
# формулируй синтаксическую форму, а не подстроку.
#
# Вызов — это имя со скобкой или с обратной кавычкой; импорт — это `import` со
# спецификатором; зависимость — это ключ в package.json. Прозаическое
# упоминание удалённой вещи обязано быть разрешено: комментарий, объясняющий,
# почему чего-то больше нет, называет это по имени — иначе он бесполезен.
#
# Ограничение помощника, о котором важно помнить: он отбрасывает СТРОЧНЫЕ
# комментарии (`//`, `#`), но НЕ блочные `/* ... */`. Блок-парсер здесь
# сознательно не заводится: наивный стриппер спотыкается о `/*` внутри строк и
# регулярных выражений и может вычистить настоящий код, а это ложный PASS —
# отказ хуже болезни. Для файлов с блочными комментариями формулируйте
# синтаксическую форму либо опирайтесь на более сильный гейт (сборка bundle,
# компилятор, тесты).
#
# Вид комментария выбирается по расширению файла.
#
# Раньше отбрасывались только строки, начинающиеся с `//`, поэтому сканы по
# tools/build/*.sh не работали вовсе: там комментарии начинаются с решётки, и
# объяснение «почему этого больше нет» считалось за код.
#
# Решётка при этом отбрасывается ТОЛЬКО в shell. В шаблоне Vue строка вполне
# может начинаться с `#default="scope"` — это сокращение для v-slot, а не
# комментарий, и общий фильтр молча выбрасывал бы её из скана.
#
# Несуществующий путь — ОТКАЗ, а не пустой вывод. Прежнее `2>/dev/null || true`
# превращало опечатку в имени файла в пустую строку, а пустая строка для
# проверок вида «запрещённой конструкции здесь нет» означает успех. Скан,
# который молча проверяет пустоту, хуже отсутствующего: он отчитывается зелёным.
code_without_comments() {
[ -f "$1" ] || fail "code_without_comments: файл не найден: $1"
case "$1" in
# grep -v возвращает 1, когда КАЖДАЯ строка файла — комментарий. Это
# законный результат, а не отказ, поэтому статус здесь гасится.
*.sh) grep -vE '^[[:space:]]*#' "$1" || true ;;
*) grep -v '^[[:space:]]*//' "$1" || true ;;
esac
}
# code_has / code_lacks — поиск по КОДУ файла без пайплайна.
#
# Пайплайн здесь был ловушкой. Поиск с флагом -q прекращает чтение на первом
# совпадении, продюсер получает SIGPIPE, и под `pipefail` статус всей
# конструкции становится 141 — то есть НЕНУЛЕВЫМ именно тогда, когда совпадение
# НАЙДЕНО. Для отрицательных проверок («такой конструкции в коде нет») это
# давало бы ложный PASS: найденная запрещённая конструкция читалась бы как её
# отсутствие. Здесь беду удерживало `|| true` внутри помощника, гасившее 141, —
# то есть корректность гейта держалась на побочном эффекте подавления ошибок,
# а не на чём-то заявленном.
#
# Отказ чтения проверяется ЯВНО, а не через errexit. В контексте `! code_has …`
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
# fenced_code_of отдаёт КОМАНДЫ документа — строки внутри ```-блоков.
#
# Нужен по той же причине, что и code_without_comments: комментарий или абзац,
# объясняющий, почему конструкции здесь больше нет, обязан называть её по имени,
# иначе он бесполезен. Гейт «такой команды в документации нет» без этого падал бы
# ровно на том объяснении, которое описывает выполненную им же работу.
fenced_code_of() {
[ -f "$1" ] || fail "fenced_code_of: файл не найден: $1"
awk '/^```/ { inside = !inside; next } inside { print }' "$1"
}
code_has() {
local file="$1"
shift
local code
code="$(code_without_comments "$file")" \
|| fail "acceptance: не удалось прочитать код файла $file"
grep -q "$@" <<<"$code"
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
shift
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_has "$file" -F -- "$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"
log_step "Acceptance: package layout sanity"
[ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable"
[ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing"
log_step "Acceptance: project license is shipped with the package"
[ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package"
grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \
|| fail "acceptance: packaged LICENSE must be AGPL-3.0-only"
grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \
|| fail "acceptance: package metadata must declare license=AGPL-3.0-only"
log_step "Acceptance: orchestrator CLI help path"
"$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true
log_step "Acceptance: firewall mode defaults in config"
grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config"
log_step "Acceptance: baseline domain/public host/ssh defaults"
grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro"
grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro"
grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323"
log_step "Acceptance: force password change production default"
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
log_step "Acceptance: config schema version is declared"
# Значение берётся из versions.env, а не пишется числом: захардкоженная
# двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про
# эту строку. Источник истины у схемы ровно один.
grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}"
log_step "Acceptance: public endpoint policy is declared and strict by default"
grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict"
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must not hardcode an obfs type"
log_step "Acceptance: modern server baseline is present in the template"
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: congestion block missing from hysteria template"
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: quic block missing from hysteria template"
log_step "Acceptance: post-install env derives obfs type from resolved context"
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must not hardcode an obfs type"
log_step "Acceptance: production defaults are declared in exactly one module"
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
local default_declarations
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
| grep -v 'orchestrator/src/config/profile.ts' || true)"
[ -z "$default_declarations" ] \
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolution"
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolved_at"
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
log_step "Acceptance: install-time never resolves a moving latest"
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|| fail "acceptance: install-time code must not query the upstream release API"
! grep -rq 'download.hysteria.network' orchestrator/src \
|| fail "acceptance: install-time code must not use the moving latest download URL"
log_step "Acceptance: smoke verifies config semantics, not substrings"
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must verify the effective config semantically"
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|| fail "acceptance: export must read the raw YAML instead of the typed model"
log_step "Acceptance: frontend ACME diagnostics preserve upstream provider names"
# Этот гейт проверял НЕ ТО, и его предмет исчез вместе с прежней страницей.
#
# Раньше он требовал, чтобы `apps/frontend/src/views/hysteria/list/index.vue`
# содержал жёстко заданный список ACME DNS-провайдеров
#
# cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr
#
# и не содержал `namedotcom`, удалённый upstream в Hysteria 2.11.0. Это имело
# смысл ровно до тех пор, пока панель ПРЕДЛАГАЛА оператору выбор провайдера:
# список в UI был вторым экземпляром upstream-реестра и мог от него отстать.
#
# Панель больше ничего не предлагает: конфигом владеет оркестратор, маршрутов
# записи в API нет, а страница Hysteria стала read-only диагностикой. Имя
# провайдера читается из фактического конфига и показывается как есть, поэтому
# реестра в UI нет и быть не должно — новый upstream-провайдер отображается
# без единой правки панели.
#
# Возврат старого списка ради прохождения этого grep'а создал бы фиктивный
# реестр, существующий только для гейта. Поэтому проверяется действующий
# контракт: провайдер приходит из профиля строкой и рисуется как значение.
local hysteria_page="apps/frontend/src/views/hysteria/list/index.vue"
local hysteria_types="apps/frontend/src/api/config/types.ts"
code_has "$hysteria_page" -F -- 'text(profile.acme.dnsProvider)' \
|| fail "acceptance: the panel must render the ACME DNS provider reported by the backend"
code_has "$hysteria_types" -E -- 'dnsProvider:[[:space:]]*string[[:space:]]*\|[[:space:]]*null' \
|| fail "acceptance: the ACME profile must carry the provider name as a generic string"
# Значения параметров DNS-провайдера — ключ от DNS-зоны, и read-only экрану
# они не нужны: показываются только имена.
code_has "$hysteria_types" -F -- 'dnsConfigKeys: string[]' \
|| fail "acceptance: the ACME DNS parameters must be exposed as key names only"
# Селектор провайдера означал бы возврат к «панель владеет конфигом».
! code_has "$hysteria_page" -F -- '<el-select' \
|| fail "acceptance: the Hysteria diagnostics page must remain read-only"
log_step "Acceptance: systemd unit production env"
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
log_step "Acceptance: docs matrix markers"
grep -q 'Fix20 production matrix' docs/testing/11-5-negative-and-matrix.md || fail "acceptance: fix20 matrix section missing"
log_step "Acceptance: machine auth URL in templates"
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL"
log_step "Acceptance: smoke auth checks are tokenized"
grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token"
# Проверяется контракт, а не литерал URL.
#
# Регрессия приёмки: здесь стоял grep по строке
# `hysteria2/auth?access_token=${...}`. После переезда machine-auth на
# /internal/hysteria/auth и централизации пути в профиле такой строки в
# smoke.ts не существует — приёмка падала на корректном коде, причём в самом
# конце сборки, внутри verify_archive. Единственный источник истины у пути
# один, поэтому и проверять нужно обращение к нему.
grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must take the machine-auth path from the production profile"
grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke auth URL must be built by the production helper (tokenized)"
! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must not assemble the machine token into a URL by hand"
log_step "Acceptance: bootstrap peer can pass auth smoke"
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
log_step "Acceptance: frontend type checking is a release gate"
# Проверка типов SFC-шаблонов была фикцией: vue-tsc 0.35 не находил ничего, а
# на современном том же коде — 142 ошибки. Инвариант обязан жить в сборке, а
# не в намерении.
"$BUN_BIN" -e '
const pkg = require("./apps/frontend/package.json");
const scripts = pkg.scripts || {};
if (!scripts.typecheck || !scripts.typecheck.includes("vue-tsc")) {
throw new Error("apps/frontend: скрипт typecheck должен запускать vue-tsc");
}
if (scripts["build:prod"].includes("vue-tsc")) {
throw new Error("build:prod снова совмещает сборку и проверку типов: ошибка типов обнаружится только после production bundle");
}
const major = Number((pkg.devDependencies["vue-tsc"] || "").replace(/^\D*/, "").split(".")[0]);
if (!Number.isInteger(major) || major < 3) {
throw new Error("vue-tsc должен быть версии 3 и выше: 0.x проверку шаблонов не выполняет");
}
' || fail "acceptance: контракт проверки типов frontend нарушен"
grep -q '"\$PNPM_BIN" run typecheck' tools/build/lib/package.sh \
|| fail "acceptance: сборка не запускает проверку типов frontend"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
const check = source.indexOf("run typecheck");
const build = source.indexOf("run build:prod");
if (check < 0 || build < 0) throw new Error("не найдены шаги typecheck/build:prod");
if (check > build) throw new Error("проверка типов идёт после сборки bundle");
' || fail "acceptance: проверка типов обязана предшествовать сборке bundle"
log_step "Acceptance: local SVG icons are rendered from an in-repo sprite"
# vite-plugin-svg-icons убран: он не обновлялся с 2022 года и тянул svgo 2.8,
# postcss 5.2.18 и image-size 0.5.5, у последней advisory прямо сообщает
# `Patched versions: <0.0.0` — исправленной версии не существует.
! grep -q '"vite-plugin-svg-icons"' apps/frontend/package.json \
|| fail "acceptance: неподдерживаемый vite-plugin-svg-icons вернулся в зависимости"
grep -q 'registerSvgIcons' apps/frontend/src/main.ts \
|| fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения"
# Литерального скана по `virtual:svg-icons-register` здесь СОЗНАТЕЛЬНО нет.
#
# Он ловил не код, а прозу: единственное вхождение строки в дереве — в
# блочном комментарии `components/SvgIcon/sprite.ts`, который объясняет, что
# именно заменено. Комментарий, называющий удалённую вещь по имени, обязан
# существовать — иначе он бесполезен, — и гейт, падающий на нём, запрещает
# документировать собственную работу.
#
# Утверждение при этом не потеряно, а обеспечено более сильной проверкой:
# production `vite build` выше по сборке уже прошёл. Активный
# `import "virtual:svg-icons-register"` при отсутствующем плагине не
# разрешается резолвером, и сборка bundle падает — то есть проверяется
# исполняемый импорт, а не совпадение подстроки.
# Контракт ассета проверяется ОБЩЕЙ функцией, а не второй её копией здесь.
#
# Раньше в этом месте лежала самостоятельная реализация проверки viewBox —
# построчный двойник кода из sprite.ts. Пока проверялась одна вещь, это
# выглядело безобидно; с добавлением проверки цвета две копии правил
# разошлись бы так же, как разошлись две копии правила имени пира. Теперь
# findIconContractViolations живёт рядом с кодом, который строит symbol, и
# зовётся отсюда.
#
# Проверяется: система координат (без viewBox `<use>` рисует иконку в
# натуральную величину и обрезает её), отсутствие литеральных цветов у
# монохромных иконок, отсутствие инлайнового style и непустого <style>,
# отсутствие растра.
"$BUN_BIN" -e '
const fs = require("node:fs");
const { findIconContractViolations, iconName } =
await import("./apps/frontend/src/components/SvgIcon/symbol.ts");
const dir = "apps/frontend/src/assets/icons";
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg"));
if (files.length === 0) throw new Error("каталог локальных иконок пуст");
const broken = [];
for (const file of files) {
broken.push(...findIconContractViolations(fs.readFileSync(`${dir}/${file}`, "utf8"), iconName(file)));
}
if (broken.length) throw new Error("иконки не соответствуют контракту спрайта:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не соответствует контракту спрайта"
log_step "Acceptance: icon colour is inherited, not patched per icon"
# Прямой запрет из требований к исправлению: маскировать дефект конвейера
# отрисовки фильтром или перекрашивать иконку по её имени нельзя.
#
# Ищется СИНТАКСИЧЕСКАЯ ФОРМА: правило для .svg-icon с filter и селектор по
# атрибуту icon-class. Комментарий, объясняющий, почему их нет, обязан
# называть их по имени.
# Правило может занимать несколько строк, поэтому проверка читает файл
# целиком, а не построчно: grep здесь дал бы ложный PASS на любом
# отформатированном CSS.
"$BUN_BIN" -e '
const fs = require("node:fs");
const files = fs.readdirSync("apps/frontend/src/styles")
.map((f) => `apps/frontend/src/styles/${f}`)
.concat(["apps/frontend/src/components/SvgIcon/index.vue"]);
const offenders = [];
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
if (/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i.test(source)) {
offenders.push(`${file}: цвет иконок маскируется CSS-фильтром`);
}
if (/\[icon-class[~^*$|]?=/i.test(source)) {
offenders.push(`${file}: цвет задаётся по имени конкретной иконки`);
}
}
if (offenders.length) throw new Error(offenders.join("\n"));
' || fail "acceptance: дефект отрисовки иконок замаскирован вместо исправления"
# Наследование обязано быть объявлено: ассет без литерального цвета сам по
# себе цвета не даёт, он его получает от компонента.
grep -q 'fill: currentcolor' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon больше не наследует цвет через currentColor"
# Проп цвета у компонента приглашал чинить отрисовку точечно, в обход общего
# контракта. Проверяется форма: атрибут fill на элементе use.
! grep -qE '<use[^>]*\bfill\s*=' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon снова принимает цвет параметром"
log_step "Acceptance: peer secret generation belongs to the server"
# «Оставьте пустым — сгенерируем автоматически» обязано выполняться для всех
# дверей: панели, прямого вызова API, импорта и будущих клиентов. Генерация
# во frontend выполняла бы обещание ровно для одной из них.
code_has apps/service/peer_secret.go -F -- 'func GeneratePeerSecret' \
|| fail "acceptance: генерация секрета пира не объявлена на сервисном слое"
code_has apps/service/peer_secret.go -F -- 'util.RandomString' \
|| fail "acceptance: генерация секрета пира не использует общий крипто-генератор"
# Импорт без секрета обязан давать пира, неотличимого от созданного формой.
code_has apps/service/peer.go -F -- 'GeneratePeerSecret(name)' \
|| fail "acceptance: импорт пиров генерирует секрет собственным способом"
local frontend_rng_hits
frontend_rng_hits="$(grep -rlE 'crypto\.getRandomValues|Math\.random' apps/frontend/src || true)"
[ -z "$frontend_rng_hits" ] \
|| fail "acceptance: панель генерирует случайные значения сама; секреты пиров создаёт сервер. Найдено:
$frontend_rng_hits"
log_step "Acceptance: validation failures name the field and the rule"
# Раньше и разбор тела, и нарушение любого правила любого поля превращались в
# одно слово `invalid`, а слой vo выбирал HTTP-семантику СРАВНЕНИЕМ текста
# сообщения с тремя литералами. Панель не могла ни подсветить поле, ни
# локализовать причину, не разбирая прозу.
code_has apps/controller/validator.go -F -- 'vo.FailValidation' \
|| fail "acceptance: отказ валидации перестал быть структурированным"
code_has apps/controller/validator.go -F -- 'describeValidationErrors' \
|| fail "acceptance: причины отказа валидации не раскладываются по полям"
code_has apps/model/vo/result.go -F -- 'Errors []FieldError' \
|| fail "acceptance: ответ об ошибке больше не несёт машиночитаемых причин"
# Классификация по тексту сообщения не должна вернуться: ищется форма
# сравнения, а не упоминание — комментарий выше в result.go обязан называть
# убранный приём по имени.
! code_has apps/model/vo/result.go -E -- 'constant\.[A-Za-z]+Error *== *message' \
|| fail "acceptance: код ответа снова выводится сравнением текста сообщения"
local generic_invalid_hits
generic_invalid_hits="$(code_mentions_in 'vo.Fail(constant.InvalidError' apps/controller apps/middleware)"
[ -z "$generic_invalid_hits" ] \
|| fail "acceptance: обобщённый отказ «invalid» вернулся в: $generic_invalid_hits"
# Панель обязана выбирать фразу по коду, а не по тексту ответа.
code_has apps/frontend/src/utils/api-message.ts -F -- 'error.code.' \
|| fail "acceptance: панель не локализует причины отказа по коду"
log_step "Acceptance: Flamy attribution is application-owned, not operator config"
# Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
# подпись разработчика: ни через панель, ни через hy2xs.env, ни через таблицу
# `config`.
[ -f apps/frontend/src/constants/branding.ts ] \
|| fail "acceptance: внутренние константы бренда отсутствуют"
code_has apps/frontend/src/constants/branding.ts -F -- 'https://flamy.studio' \
|| fail "acceptance: адрес атрибуции не объявлен в константах бренда"
local flamy_carriers
flamy_carriers="$(grep -rl 'flamy\.studio' apps/frontend/src || true)"
[ "$flamy_carriers" = "apps/frontend/src/constants/branding.ts" ] \
|| fail "acceptance: адрес атрибуции размазан по исходникам панели: $flamy_carriers"
local flamy_config_hits
flamy_config_hits="$(grep -ril 'flamy' \
package/config package/templates orchestrator/src \
apps/model/constant apps/dao || true)"
[ -z "$flamy_config_hits" ] \
|| fail "acceptance: адрес атрибуции стал операторской настройкой. Найдено в: $flamy_config_hits"
log_step "Acceptance: frontend i18n does not touch Pinia at module import"
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders"
grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET"
grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders"
run_clean_install_acceptance "$package_dir"
}
# Приёмка политики clean-install-only и связанных с ней инвариантов.
run_clean_install_acceptance() {
local package_dir="$1"
log_step "Acceptance: installer runs a read-only preflight before touching the host"
grep -q 'preflight-install' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must run the read-only preflight before mutating the host"
grep -q 'PHASE 0' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must document the read-only phase boundary"
grep -q 'preflightInstall' orchestrator/src/cli.ts \
|| fail "acceptance: orchestrator must expose the preflight-install command"
log_step "Acceptance: the read-only phase is enforced by a guard, not by convention"
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/preflight-install.ts \
|| fail "acceptance: preflight-install must enable the read-only guard"
grep -q 'assertMutationAllowed' orchestrator/src/lib/fs.ts \
|| fail "acceptance: fs writes must be guarded during the read-only phase"
grep -q 'assertMutationAllowed' orchestrator/src/lib/process.ts \
|| fail "acceptance: mutating runners must be guarded during the read-only phase"
log_step "Acceptance: preflight passes before the first install-state write"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const preflight = source.indexOf("await preflight(context");
const state = source.indexOf("await advanceInstallState(");
if (preflight < 0 || state < 0) {
throw new Error("could not locate preflight/advanceInstallState in install.ts");
}
if (preflight > state) {
throw new Error("install writes install-state before preflight");
}
' || fail "acceptance: install must not write install-state before a successful preflight"
log_step "Acceptance: rollback is ownership-aware, not message-driven"
grep -q 'classifyFailure(ownership' orchestrator/src/commands/install.ts \
|| fail "acceptance: failure classification must be driven by ownership, not by the error message"
grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \
|| fail "acceptance: rollback must never stop services it did not deploy"
log_step "Acceptance: a touched install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал маркер установки, поэтому падение
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
# а install-state.json оставался на хосте и ломал следующую установку.
grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \
|| fail "acceptance: classifyFailure must account for a touched install-state"
! grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|| fail "acceptance: stateWritten вернулся; флаг обязан называться stateTouched и взводиться до записи"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const body = source.slice(source.indexOf("export function classifyFailure"));
const preApply = body.indexOf("return \"fatal_pre_apply\"");
const stateTouched = body.indexOf("ownership.stateTouched");
if (preApply < 0 || stateTouched < 0) {
throw new Error("could not locate classifyFailure branches");
}
if (stateTouched > preApply) {
throw new Error("stateTouched is checked after the fatal_pre_apply fallback");
}
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was touched"
log_step "Acceptance: the install-state flag is raised before the write, like every other one"
# Запись маркера — это mkdir, write и chown. Отказ последней оставляет файл на
# диске, поэтому флаг обязан отвечать на вопрос «сюда мы могли влезть», а не
# «запись удалась».
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const start = source.indexOf("async function advanceInstallState");
if (start < 0) throw new Error("advanceInstallState is missing");
// Границей тела служит следующее объявление верхнего уровня.
const rest = source.slice(start + 1);
const end = rest.search(/\n(export )?(async )?function /);
const body = end < 0 ? rest : rest.slice(0, end);
const flag = body.indexOf("ownership.stateTouched = true");
const write = body.indexOf("await writeInstallState(");
if (flag < 0 || write < 0) throw new Error("could not locate the flag and the write");
if (flag > write) throw new Error("stateTouched is raised after writeInstallState");
' || fail "acceptance: the install-state ownership flag must be raised before the write"
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
# систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const steps = [
["depsTouched", "await installDeps("],
["filesystemTouched", "await prepareFilesystem("],
["uiTouched", "await deployUi("],
["hysteriaTouched", "await installHysteria("],
["configTouched", "await generateConfig("],
["unitsTouched", "await deploySystemd("],
["firewallTouched", "await applyFirewall("],
["postInstallTouched", "await writePostInstallEnv("],
["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("]
];
for (const [flag, call] of steps) {
const flagAt = source.indexOf("ownership." + flag + " = true");
const callAt = source.indexOf(call);
if (flagAt < 0) throw new Error("missing ownership flag: " + flag);
if (callAt < 0) throw new Error("missing step call: " + call);
if (flagAt > callAt) throw new Error(flag + " is raised after " + call);
}
' || fail "acceptance: ownership flags must be raised before the mutating step they cover"
log_step "Acceptance: the read-only phase has no universal runner to slip through"
# Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`,
# guard держался на внимательности автора правки.
grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit read-only runner"
grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit mutating runner"
# Дефис в списке исключённых предшествующих символов — не косметика.
#
# Скан ищет ВЫЗОВ прежнего раннера: имя, за которым сразу идёт обратная
# кавычка tagged template. Без дефиса под это описание попадала любая проза
# вида `systemd-run` — имя команды, взятое в кавычки в комментарии, — и гейт
# падал на строке документации, не содержащей никакого вызова. В JavaScript
# идентификатор не может начинаться после дефиса, поэтому исключение точное:
# ни один настоящий вызов оно не пропускает.
! grep -rEq '(^|[^-A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back"
# Порог считается от числа мутирующих раннеров, а не задан константой: новый
# раннер обязан приносить с собой и проверку guard'а, а не проходить под
# запасом, оставленным предыдущей правкой.
local mutating_runners guarded_runners
mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)"
guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$mutating_runners" -ge 5 ] \
|| fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)"
[ "$guarded_runners" -gt "$mutating_runners" ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
# Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию.
# Всё остальное в smoke держалось на выборе раннера автором правки: читающие
# команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли
# через мутирующий namespace, поэтому настоящая мутация, случайно добавленная
# в smoke, ничем бы от них не отличалась и была бы разрешена в doctor молча.
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан выполняться под read-only guard, а не только выставлять skipServiceStart"
grep -q 'disableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан снимать guard в finally"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
const enable = source.indexOf("enableReadOnlyGuard(");
const work = source.indexOf("await readText(options.sourceConfigPath)");
const finallyAt = source.indexOf("} finally {");
const disable = source.indexOf("disableReadOnlyGuard()");
if (enable < 0 || work < 0 || finallyAt < 0 || disable < 0) {
throw new Error("не найдены включение guard, работа и finally");
}
if (enable > work) throw new Error("guard включается после начала работы");
if (disable < finallyAt) throw new Error("guard снимается не в finally");
' || fail "acceptance: guard doctor обязан охватывать весь проход и сниматься в finally"
# В smoke допустим ровно один мутирующий вызов — рестарт под флагом, который
# doctor выставляет принудительно.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const calls = source.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("//"))
.filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line));
if (calls.length !== 1) {
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
}
if (!calls[0].includes("systemctl restart hysteria-server hy2xs-admin")) {
throw new Error("единственный мутирующий вызов smoke — не рестарт сервисов: " + calls[0]);
}
' || fail "acceptance: наблюдение в smoke обязано выполняться read-only раннерами"
# Пауза между двумя чтениями не является операцией над хостом.
! grep -Eq 'runMutating[A-Za-z]*`sleep' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: ожидание между попытками снова выполняется мутирующим раннером"
log_step "Acceptance: reset-admin tells a storage failure apart from a missing admin"
# Склейка ErrStorage и ErrAdminUserNotFound уводила команду в ветку СОЗДАНИЯ:
# при транзиентном отказе чтения на сервере появлялась вторая рабочая учётка
# с паролем, уже напечатанным на экран.
grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \
|| fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных"
! code_has apps/cmd/reset.go -F -- 'hash, _ :=' \
|| fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8");
const start = source.indexOf("func resetAdminCredentials");
if (start < 0) throw new Error("resetAdminCredentials отсутствует");
const body = source.slice(start);
if (!/default:\s*\n\s*return fmt\.Errorf/.test(body)) {
throw new Error("неизвестная ошибка чтения не приводит к отказу");
}
const save = body.indexOf("dao.SaveAdminUser(");
const notFound = body.indexOf("errors.Is(err, dao.ErrAdminUserNotFound)");
if (save < 0 || notFound < 0) throw new Error("не найдены ветка создания и её условие");
if (notFound > save) throw new Error("создание учётной записи не ограничено веткой «записи нет»");
' || fail "acceptance: создание администратора допустимо только при подтверждённом отсутствии записи"
log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server"
[ -f orchestrator/src/steps/networkEndpoint.ts ] \
|| fail "acceptance: the public endpoint invariant module is missing"
! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \
|| fail "acceptance: the server address must be resolved locally, not via an external service"
grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \
|| fail "acceptance: local public IPv4 set must come from the host interfaces"
log_step "Acceptance: purge and clean-host describe the same boundary"
local purged_path
for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do
grep -qF "$purged_path" tools/legacy/purge-v0.sh \
|| fail "acceptance: purge-v0.sh no longer removes $purged_path"
done
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
# Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его
# создаёт steps/bootstrap.ts, и две копии строки разошлись бы.
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
log_step "Acceptance: admin secrets never reach a persistent export file"
# Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export
# оставлял на диске JSON с расшифрованными секретами пиров.
[ ! -f apps/util/export.go ] \
|| fail "acceptance: the file-based export helper came back"
! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \
|| fail "acceptance: the persistent export directory came back"
grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \
|| fail "acceptance: peer export must be served from memory"
log_step "Acceptance: peer import is validated as strictly as peer creation"
grep -q 'ValidatePeerImportBatch' apps/service/peer.go \
|| fail "acceptance: peer import must validate the whole batch before writing"
grep -q 'DisallowUnknownFields' apps/controller/peer.go \
|| fail "acceptance: peer import must reject unknown fields instead of silently defaulting"
grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \
|| fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer"
log_step "Acceptance: missing config schema marker is treated as legacy"
grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \
|| fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted"
log_step "Acceptance: reconfigure/repair verify the installation generation"
grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure/repair must verify release line and config schema in install-state"
grep -q 'release_line' orchestrator/src/lib/installState.ts \
|| fail "acceptance: install-state must carry the product release line"
log_step "Acceptance: diagnostics redaction is structural"
grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: YAML redaction must walk the document, not match lines"
# Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в
# заголовок mapping'а и оставляло вложенный auth.http.url с machine token.
! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url"
log_step "Acceptance: dead updater/config-write routes stay removed"
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
#
# Тесты исключены по той же причине, и это не послабление: router_test.go
# ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице
# маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном
# регрессионном тесте — то есть добавление теста, закрепляющего удаление
# маршрута, ломало сборку.
local dead_route
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do
! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \
|| fail "acceptance: removed route ${dead_route} came back"
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
done
log_step "Acceptance: e2e uses the production share URI generator"
grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: e2e must build the share URI with production code"
! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: the bash share-URI implementation must not come back"
log_step "Acceptance: upstream checksums are verified before the lock is written"
grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \
|| fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt"
log_step "Acceptance: toolchain checksums live in versions.env"
grep -q '^BUN_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun x64 artifact"
grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun baseline artifact"
grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Go toolchain archive"
grep -q '^NODE_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Node.js archive"
! grep -q '^HYSTERIA_VERSION=' versions.env \
|| fail "acceptance: versions.env declares the Hysteria policy, not a pinned version"
log_step "Acceptance: admin version comes from the build contract"
grep -q 'var Version = "dev"' apps/model/constant/system.go \
|| fail "acceptance: admin version must be injected via ldflags, not hardcoded"
grep -q 'constant.Version=v' tools/build/lib/package.sh \
|| fail "acceptance: the build must inject the admin version from versions.env"
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
[ -f docs/operations/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
! grep -q 'purge-v0' "$package_dir/install.sh" \
|| fail "acceptance: the installer must never run destructive cleanup on its own"
run_transaction_boundary_acceptance
run_single_owner_acceptance "$package_dir"
run_secret_channel_acceptance
run_atomic_import_acceptance
run_access_revocation_acceptance
run_observability_acceptance
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_admin_login_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Вход в панель: барьеры, без которых RC2 повторяется.
#
# Гейт существует по той же причине, что и гейт детектора гонок: проверки,
# закрывающие класс дефекта, не имеют права тихо исчезнуть. Удалённый тест
# оставляет сборку зелёной, а выпускаемый артефакт — неотличимым.
#
# Проверяется НАЛИЧИЕ барьеров, а не поведение: поведение доказывают сами
# тесты, и они обязательны (run_admin_tests, run_orchestrator_tests,
# run_frontend_tests). Здесь удерживается то, что их есть кому доказывать.
run_admin_login_acceptance() {
log_step "Acceptance: every validation tag is proven to exist before release"
# Дефект RC2 целиком: тег `validateStr` пережил переименование правила в
# `credentialStr`, валидатор паниковал на КАЖДОМ входе, gin.Recovery отдавал
# HTTP 500, установка при этом объявляла успех.
code_has apps/controller/auth_test.go -F -- 'func TestEveryValidationTagIsRegistered' \
|| fail "acceptance: барьер существования правил валидации удалён — stale-тег снова станет runtime-дефектом"
code_has apps/controller/auth_test.go -F -- 'validate.Var(' \
|| fail "acceptance: барьер обязан предъявлять тег валидатору, а не сравнивать строки"
# Наивная замена барьера не работает: цепочка правил поля обрывается на
# первом несработавшем, поэтому нулевое DTO отказывает по `required` и до
# испорченного тега не доходит. Барьер обязан ходить по ИСХОДНИКАМ.
code_has apps/controller/auth_test.go -F -- 'filepath.WalkDir' \
|| fail "acceptance: барьер перестал обходить исходники модели"
log_step "Acceptance: the admin credential contract has a single owner"
# Контракт живёт в LEAF-пакете, а не в service. Это не вкусовщина: его зовут
# util.HashPassword и dao (bootstrap), а `service` импортирует `util` —
# обратный импорт был бы циклическим. Пока контракт лежал в service,
# HashPassword завёл собственную проверку `len(TrimSpace(p)) < 6`, и она
# разошлась с остальным продуктом.
[ -f apps/credential/admin.go ] \
|| fail "acceptance: контракт учётных данных администратора отсутствует"
[ ! -f apps/service/admin_credentials.go ] \
|| fail "acceptance: контракт вернулся в service, откуда его не может позвать util.HashPassword"
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminUsername' \
|| fail "acceptance: правило логина снова несёт собственную копию набора символов"
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminPassword' \
|| fail "acceptance: правило пароля снова несёт собственную копию границ"
# Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже
# приводили к отказу, который невозможно объяснить оператору.
! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \
|| fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом"
log_step "Acceptance: the password contract knows the bcrypt byte limit"
# Границ у пароля ДВЕ и они в разных единицах. bcrypt читает 72 БАЙТА и
# отвечает ErrPasswordTooLong; 64 символа — это от 64 до 256 байт. Пока
# байтовой границы не было, пароль из 64 кириллических букв проходил
# оркестратор, DTO и панель, а падал в хешировании — системной ошибкой на
# штатной смене пароля и отсутствием администратора после установки.
code_has apps/credential/admin.go -F -- 'AdminPasswordMaxBytes = 72' \
|| fail "acceptance: предел bcrypt в байтах не объявлен в контракте"
code_has orchestrator/src/config/profile.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|| fail "acceptance: оркестратор снова не знает про предел bcrypt в байтах"
code_has apps/frontend/src/constants/credentials.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|| fail "acceptance: панель снова не знает про предел bcrypt в байтах"
# Границы пароля живут ВНУТРИ правила: тег валидатора умеет считать только
# символы, а границу в байтах им не выразить вовсе.
local password_dto
for password_dto in apps/model/dto/auth.go apps/model/dto/admin_user.go; do
code_has "$password_dto" -F -- 'adminPassword' \
|| fail "acceptance: $password_dto больше не проверяет пароль общим правилом"
! code_has "$password_dto" -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*"' \
|| fail "acceptance: в $password_dto вернулась граница длины рядом с правилом пароля"
done
log_step "Acceptance: the password is never trimmed on its way to bcrypt"
# Пробел по краям объявлен ЧАСТЬЮ пароля. Пока HashPassword триммил значение,
# пароль `abcde ` принимали все двери продукта и не мог захешировать никто; а
# пока bootstrap триммил HY2XS_ADMIN_INITIAL_PASSWORD, учётная запись
# создавалась не с тем паролем, который оператор записал в hy2xs.env.
code_has apps/util/encrypt.go -F -- 'credential.IsValidAdminPassword(password)' \
|| fail "acceptance: HashPassword снова проверяет пароль собственным правилом"
! code_has apps/util/encrypt.go -F -- 'strings.TrimSpace(password)' \
|| fail "acceptance: в HashPassword вернулся TrimSpace — пароль с краевым пробелом снова нельзя захешировать"
! code_has apps/dao/sqlite.go -F -- 'strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))' \
|| fail "acceptance: bootstrap снова обрезает пароль первого администратора"
code_has apps/dao/sqlite.go -F -- 'credential.IsValidAdminPassword(adminPassword)' \
|| fail "acceptance: bootstrap снова не проверяет пароль против контракта"
log_step "Acceptance: hy2xs.env is a format, not a lucky interpolation"
# У файла ДВА читателя, и один из них — systemd (EnvironmentFile=). Пока
# значения писались интерполяцией, а читались split('=') с trim(), пароль с
# краевым пробелом, кавычкой или обратным слешем терялся ещё до запуска
# админки — при том, что продукт обещает неограниченный набор символов.
[ -f orchestrator/src/lib/envFile.ts ] \
|| fail "acceptance: модуль формата env-файлов отсутствует"
code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \
|| fail "acceptance: разбор hy2xs.env снова расходится с systemd"
code_has orchestrator/src/config/env.ts -F -- 'renderEnvFile(entries)' \
|| fail "acceptance: запись hy2xs.env снова идёт интерполяцией"
code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \
|| fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией"
# Экранируются ТОЛЬКО те символы, которые systemd разворачивает обратно.
code_has orchestrator/src/lib/envFile.ts -F -- 'SHELL_NEED_ESCAPE' \
|| fail "acceptance: кодировщик env потерял ссылку на правила systemd"
log_step "Acceptance: the transport domain is systemd's, not ours"
# systemd прогоняет ключ и значение через utf8_is_valid перед тем, как принять
# пару (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там - это
# -EINVAL, то есть НЕзагруженный EnvironmentFile и юнит, который не стартует.
# Пока контракт знал только про управляющие символы, пароль `abcde` плюс
# U+FDD0 - шесть символов, восемь байт - проходил все двери продукта и
# оставлял сервер без работающей панели.
code_has apps/credential/admin.go -F -- 'func IsEnvTransportableText' \
|| fail "acceptance: контракт снова не знает домена значений systemd"
code_has apps/credential/admin.go -F -- 'IsEnvTransportableText(password)' \
|| fail "acceptance: правило пароля перестало проверять домен транспорта"
code_has orchestrator/src/lib/envFile.ts -F -- 'export function isEnvTransportable' \
|| fail "acceptance: оркестратор снова не знает домена значений systemd"
code_has orchestrator/src/config/profile.ts -F -- 'isEnvTransportable(value)' \
|| fail "acceptance: правило пароля оркестратора перестало проверять домен транспорта"
# Границы взяты из unichar_is_valid: U+FDD0..U+FDEF и (cp & 0xFFFE) == 0xFFFE.
local unicode_domain_owner
for unicode_domain_owner in apps/credential/admin.go \
orchestrator/src/lib/envFile.ts \
apps/frontend/src/constants/credentials.ts; do
code_has "$unicode_domain_owner" -E -- '0[xX][fF][dD][dD]0' \
|| fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF"
code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \
|| fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF"
done
# Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без
# проверки это была бы не ошибка, а тихая подмена секрета.
local surrogate_owner
for surrogate_owner in orchestrator/src/lib/envFile.ts \
apps/frontend/src/constants/credentials.ts; do
code_has "$surrogate_owner" -E -- '0[xX][dD]800' \
|| fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты"
done
# Порт автомата обязан повторять upstream буквально: в состоянии
# DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой
# перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd.
code_has orchestrator/src/lib/envFile.ts -F -- 'c !== "\n"' \
|| fail "acceptance: escape в двойных кавычках разошёлся с автоматом systemd"
log_step "Acceptance: unusable configuration is rejected before the first mutation"
# Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала
# на шаге записи файла - уже после bootstrap оркестратора, apt и раскладки
# файловой системы, а read-only preflight-install про неё говорил PASS.
code_has orchestrator/src/config/env.ts -F -- 'validateRuntimeEnvTransport(config);' \
|| fail "acceptance: конфигурация снова проверяется на транспорт только при записи"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
const start = source.indexOf("export function parseRuntimeEnv");
if (start < 0) throw new Error("parseRuntimeEnv отсутствует");
const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig"));
if (!body.includes("validateRuntimeEnvTransport(config)")) {
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
}
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации"
log_step "Acceptance: env files are read as data, never executed"
# Файл научился нести произвольные значения, и это сделало `source` опасным:
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
# (подстановок в EnvironmentFile нет), а bash внутри двойных кавычек выполнил
# бы подстановку команд - от root, прямо в рецепте восстановления доступа.
#
# Проверяются КОМАНДЫ: объяснение, почему такой строки здесь больше нет,
# обязано называть её по имени, поэтому текст вне ```-блоков не считается.
local env_doc
while IFS= read -r env_doc; do
[ -n "$env_doc" ] || continue
if fenced_code_of "$env_doc" \
| grep -qE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|hy2xs\.env|bootstrap-admin\.secret)'; then
fail "acceptance: $env_doc снова предлагает исполнить env-файл шеллом (source/.)"
fi
if fenced_code_of "$env_doc" \
| grep -qE 'eval[[:space:]]+[^|]*(hy2xs\.env|bootstrap-admin\.secret)'; then
fail "acceptance: $env_doc снова предлагает eval над env-файлом"
fi
done <<EOF
$(find docs package/docs -name '*.md' 2>/dev/null; echo README.md)
EOF
if grep -rqE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|bootstrap-admin\.secret)' \
--include='*.sh' tools package 2>/dev/null; then
fail "acceptance: скрипт снова исполняет env-файл шеллом"
fi
log_step "Acceptance: the panel is never stricter than the server"
[ -f apps/frontend/src/constants/credentials.ts ] \
|| fail "acceptance: контракт учётных данных панели отсутствует"
local form
for form in apps/frontend/src/views/login/index.vue \
apps/frontend/src/views/admin/change-password/index.vue; do
code_has "$form" -F -- '@/constants/credentials' \
|| fail "acceptance: $form снова заводит собственное правило учётных данных"
# Неэкранированный дефис превращает `+-=` в диапазон: набор совпадал с
# серверным только потому, что обе стороны несли одну опечатку.
! code_has "$form" -F -- '_+-=' \
|| fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке"
# Встроенные min/max Element Plus делегируются async-validator, а он считает
# длину строки в единицах UTF-16: пароль из трёх эмодзи имел length 6,
# проходил минимум формы и получал отказ сервера, который панель не могла
# объяснить. Правило пароля обязано быть общим и считать code points.
code_has "$form" -F -- 'adminPasswordFormRule(' \
|| fail "acceptance: $form снова проверяет пароль встроенными min/max async-validator"
done
log_step "Acceptance: install proves that the panel lets an operator in"
# `LISTEN` и `/healthz` не отвечают на вопрос, ради которого проверяются:
# в RC2 оба были истинны при полностью недоступной панели.
code_has orchestrator/src/steps/smoke.ts -F -- 'assertAdminLoginWorks' \
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|| fail "acceptance: адрес формы входа перестал браться из профиля"
# Успех определяется КОНВЕРТОМ: админка отвечает 200 OK и на отказ тоже.
code_has orchestrator/src/steps/smoke.ts -F -- '"code"\s*:\s*20000' \
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
# Команда несёт пароль администратора и не имеет права попасть в текст ошибки:
# runReadOnly (без Secret) кладёт её туда, а текст уезжает в журнал установки
# и в diagnostics-бандл. Проверяется ТЕЛО функции, а не файл: остальной smoke
# законно пользуется обоими раннерами.
local login_body
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|| fail "acceptance: не удалось прочитать проверку входа в панель"
[ -n "$login_body" ] \
|| fail "acceptance: функция проверки входа в панель не найдена"
grep -q 'runReadOnlySecret`curl' <<<"$login_body" \
|| fail "acceptance: запрос входа не выполняется секретным раннером"
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
! grep -q 'runReadOnly`curl' <<<"$login_body" \
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
log_step "Acceptance: the negative login probe proves a rejection"
# Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не
# закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
# поэтому «неверные учётные данные отвергнуты» из неё не следовало.
code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \
|| fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP"
code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \
|| fail "acceptance: отрицательная проба не сверяет доменный код отказа"
# Литерал в исходнике теоретически может оказаться настоящим паролем — тогда
# проверка «данные отвергнуты» отчиталась бы об успешном ВХОДЕ.
! code_has orchestrator/src/steps/smoke.ts -F -- 'definitely-not-the-admin-password' \
|| fail "acceptance: пароль отрицательной пробы снова записан литералом"
# Bootstrap-секрет читается парсером формата: `cut -d= -f2-` с `trim()` был
# третьим слоем, срезающим краевые пробелы у пароля.
! code_has orchestrator/src/steps/smoke.ts -F -- 'cut -d= -f2-' \
|| fail "acceptance: bootstrap-секрет снова извлекается шелл-конвейером"
code_has orchestrator/src/steps/smoke.ts -F -- 'readBootstrapAdminSecret' \
|| fail "acceptance: bootstrap-секрет читается не парсером собственного формата"
log_step "Acceptance: the orchestrator validates what it generates"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminPassword(' \
|| fail "acceptance: HY2XS_ADMIN_INITIAL_PASSWORD снова не проверяется против контракта панели"
# Умолчание про одно и то же имя обязано быть одним. Их было три, и одно из
# них — `admin` — короче минимума панели.
code_has orchestrator/src/config/env.ts -F -- '"hy2xsadmin"' \
|| fail "acceptance: умолчание логина администратора разошлось с админкой и packaged env"
code_has apps/dao/sqlite.go -F -- 'adminUser = "hy2xsadmin"' \
|| fail "acceptance: запасное значение логина в админке разошлось с оркестратором"
grep -q '^HY2XS_ADMIN_USER=hy2xsadmin$' package/config/hy2xs.env \
|| fail "acceptance: packaged env разошёлся с умолчанием оркестратора"
}
# Планировщик принадлежит процессу и не перезапускает 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_has apps/controller/config.go -- '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_has apps/service/config.go -- 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_has apps/service/hysteria2_export.go -- '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_has apps/dao/sqlite.go -- '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_has apps/controller/log.go -- '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_has apps/util/encrypt.go -- '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 npm gate covers the whole lock graph, not only production deps"
# `--prod` не показывал уязвимости в vite/rollup, хотя они исполняются на
# build-машине и порождают production-бандл. Ровно такой класс и был найден
# (DOM clobbering в Rollup затрагивал генерируемый бандл).
! code_has tools/build/lib/security.sh -- 'audit --prod' \
|| fail "acceptance: гейт зависимостей снова проверяет только production-подграф npm"
code_has tools/build/lib/security.sh -- 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \
|| fail "acceptance: гейт зависимостей обязан проверять весь lock-граф frontend"
log_step "Acceptance: release gates never pipe into an early-exiting matcher"
# Поиск с флагом -q прекращает чтение на ПЕРВОМ совпадении и закрывает свой
# конец канала. Продюсер, у которого осталось что писать, получает SIGPIPE и
# завершается кодом 141, а `set -o pipefail` делает 141 статусом всей
# конструкции. Получается инверсия смысла: «совпадение найдено» становится
# ненулевым кодом.
#
# Для утвердительных проверок это ложный FAIL — гейт отвергает корректный
# артефакт. Для отрицательных («такой конструкции в коде нет») — ЛОЖНЫЙ PASS:
# найденная запрещённая конструкция читается как её отсутствие, и гейт
# отчитывается зелёным ровно тогда, когда обязан был остановить сборку.
#
# Порог не гипотетический: пока весь вывод продюсера помещается в буфер канала
# (64 KiB на Linux), он успевает записать всё и не блокируется, поэтому такая
# проверка годами выглядит исправной. Первый же источник крупнее буфера
# переворачивает её результат — а в этом репозитории такие файлы уже есть.
#
# Замена — here-string: `matcher PATTERN <<<"$content"`. Пайплайна нет, статус
# у конструкции ровно один, и pipefail к нему не применяется.
local piped_matcher
piped_matcher="$(grep -rn -E '\|[[:space:]]*grep[[:space:]]+-[A-Za-z]*q' \
tools/build/lib tools/build/build.sh tools/test tools/legacy package || true)"
[ -z "$piped_matcher" ] \
|| fail "acceptance: пайплайн в grep -q под pipefail вернулся; используйте here-string:
$piped_matcher"
log_step "Acceptance: the release is built with a mandatory test gate"
# SKIP_TESTS=true доходил до конца сборки и выдавал внешне неотличимый
# production-артефакт: ни metadata, ни финальная приёмка не проверяли, что
# тесты запускались. Глушил он при этом и `tsc --noEmit`, и `go vet`.
local test_bypass_file
for test_bypass_file in \
tools/build/build.sh \
tools/build/lib/package.sh \
tools/build/lib/security.sh \
tools/build/lib/versions.sh \
tools/build/lib/deps.sh \
tools/build/lib/verify.sh
do
[ -z "$(code_without_comments "$test_bypass_file" | grep -F -- 'SKIP_TESTS' || true)" ] \
|| fail "acceptance: обход тестов SKIP_TESTS вернулся в $test_bypass_file"
done
! grep -rq 'SKIP_TESTS' README.md docs tools/build/README.md \
|| fail "acceptance: README/docs снова описывают обход тестов, которого нет в сборке; место для истории — CHANGELOG.md"
grep -q '^tests_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с пройденными тестами"
# Утверждение обязано опираться на результат, а не на намерение.
grep -q 'ORCHESTRATOR_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
]) {
const start = source.indexOf(fn);
if (start < 0) throw new Error("не найдена функция " + fn);
const rest = source.slice(start);
// Границей тела служит закрывающая скобка в первой позиции строки.
// Поиск регуляркой, а не подстрокой: файл может быть выгружен с CRLF.
const end = rest.search(/\n\}[\r\n]/);
const body = end < 0 ? rest : rest.slice(0, end);
const set = body.indexOf(flag);
const run = body.lastIndexOf("|| fail");
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
}
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
# Детектор гонок — часть прогона админки, а не пожелание.
#
# В продукте есть состояние, принадлежащее ПРОЦЕССУ: учёт выданных
# разрешений на устройства и мьютекс цикла учёта. Оба существуют ровно затем,
# чтобы вести себя правильно под параллельным доступом, и обычный `go test`
# об их корректности не говорит ничего.
#
# Проверяется и то, что прогон не умеет молча пропуститься: сборка,
# пропускающая проверку при недоступном компиляторе, выдала бы внешне
# неотличимый production-артефакт — тот же класс, что и SKIP_TESTS.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
const admin = source.slice(source.indexOf("run_admin_tests()"));
const adminBody = admin.slice(0, admin.search(/\n\}[\r\n]/));
if (!adminBody.includes("run_admin_race_tests")) {
throw new Error("прогон админки больше не включает детектор гонок");
}
const start = source.indexOf("run_admin_race_tests() {");
if (start < 0) throw new Error("не найдена функция run_admin_race_tests");
const rest = source.slice(start);
const body = rest.slice(0, rest.search(/\n\}[\r\n]/));
if (!body.includes("-race")) throw new Error("детектор гонок не включён");
if (!body.includes("CGO_ENABLED=1")) {
throw new Error("детектор гонок требует cgo, а production-сборка идёт с CGO_ENABLED=0");
}
if (!/command -v (cc|gcc)[\s\S]*?\|\| *fail/.test(body)) {
throw new Error("отсутствие компилятора обязано ронять сборку, а не пропускать прогон");
}
for (const bypass of ["|| true", "SKIP_RACE", "continue"]) {
if (body.includes(bypass)) throw new Error("детектор гонок можно обойти: " + bypass);
}
' || fail "acceptance: прогон детектора гонок обязан быть обязательным"
# У шага не должно быть обходов — ни объявленных, ни забытых.
#
# Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata
# `accepted-risk`, SKIP_SECURITY_SCAN=true — `skipped`. Оба документировались
# README и docs/02 как способ выпустить релиз, зная об уязвимости, и оба
# гарантированно падали здесь же, строкой выше: приёмка требует буквально
# `true`. Сборка проходила весь цикл и умирала на последнем шаге ради
# операции, которую продукт в итоге запрещает.
#
# Противоречие закрыто в пользу строгой политики, и теперь это проверяется, а
# не только описано. Ищется КОД: комментарий выше объясняет, почему обходов
# нет, и обязан называть их по имени.
#
# Сканируются модули, которые могли бы обход вернуть, а НЕ весь tools/build.
# Причина ровно та, из-за которой в этом файле дважды падала сборка на
# корректном коде: скан по всему каталогу находил сам себя — строку
# `for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN` ниже.
# Это код, а не комментарий, поэтому code_without_comments тут не спасает.
local bypass_var bypass_scan_file bypass_hits
for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN; do
for bypass_scan_file in \
tools/build/build.sh \
tools/build/lib/security.sh \
tools/build/lib/package.sh \
tools/build/lib/versions.sh \
tools/build/lib/deps.sh \
tools/build/lib/verify.sh
do
bypass_hits="$(code_without_comments "$bypass_scan_file" | grep -F -- "$bypass_var" || true)"
[ -z "$bypass_hits" ] \
|| fail "acceptance: обход проверки зависимостей $bypass_var вернулся в $bypass_scan_file: $bypass_hits"
done
done
# И в документации тоже: описанный, но нерабочий аварийный выход — хуже, чем
# его отсутствие. Именно из-за такого описания противоречие и прожило до
# приёмки.
#
# У markdown нет строк-комментариев, поэтому code_without_comments здесь не
# применим и правило простое: место для истории — CHANGELOG.md, он под скан не
# попадает. README и docs описывают текущую политику, а не отменённую.
! grep -rq 'ALLOW_VULNERABLE_DEPENDENCIES\|SKIP_SECURITY_SCAN' README.md docs \
|| fail "acceptance: README/docs снова описывают обход проверки зависимостей, которого нет в сборке; место для истории — CHANGELOG.md"
# Единственное значение поля — true. `accepted-risk`/`skipped` не должны
# вернуться даже как строковые литералы: их некому произвести.
local dead_gate_value
for dead_gate_value in accepted-risk skipped; do
! grep -rqF "DEPENDENCY_SECURITY_GATE=\"${dead_gate_value}\"" tools/build \
|| fail "acceptance: у dependency_security_gate снова появилось значение ${dead_gate_value}"
done
log_step "Acceptance: versions.env is a developer contract, not only a build contract"
# Сборка соблюдала versions.env, а машина разработчика не проверялась никак:
# локальный Go 1.25 собирал проект успешно, пока релизный бинарь собирался на
# 1.26.7 и наследовал ЕЁ stdlib. Проверялся не тот код, который уезжает в
# production.
local doctor_script
for doctor_script in tools/dev/doctor.sh tools/dev/doctor.ps1; do
[ -f "$doctor_script" ] \
|| fail "acceptance: проверка среды разработки $doctor_script отсутствует"
grep -q 'versions.env' "$doctor_script" \
|| fail "acceptance: $doctor_script обязан читать контракт из versions.env, а не носить свои значения"
done
[ -x tools/dev/doctor.sh ] \
|| fail "acceptance: tools/dev/doctor.sh не исполняемый"
# Собственных версий у doctor быть не должно: второй список версий неизбежно
# разъедется с versions.env.
! grep -qE '(GO|NODE|PNPM|BUN)_VERSION[[:space:]]*=[[:space:]]*[0-9]' tools/dev/doctor.sh \
|| fail "acceptance: tools/dev/doctor.sh завёл собственные значения версий"
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 принадлежит оркестратору целиком.
run_single_owner_acceptance() {
local package_dir="$1"
log_step "Acceptance: install.sh does not mutate the host at all"
local mutation_hits
mutation_hits="$(grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \
"$package_dir/install.sh" || true)"
[ -z "$mutation_hits" ] \
|| fail "acceptance: install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone: $mutation_hits"
grep -q 'exec .*install --package-dir' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must hand the mutating phase over via exec"
log_step "Acceptance: the orchestrator owns its own bootstrap"
[ -f orchestrator/src/steps/bootstrap.ts ] \
|| fail "acceptance: the bootstrap step module is missing"
grep -q 'ownership.bootstrapTouched' orchestrator/src/commands/install.ts \
|| fail "acceptance: bootstrap must be covered by an ownership flag"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const flagAt = source.indexOf("ownership.bootstrapTouched = true");
const callAt = source.indexOf("await bootstrapRuntime(");
const depsAt = source.indexOf("await installDeps(");
if (flagAt < 0 || callAt < 0) throw new Error("bootstrap step is not wired into install");
if (flagAt > callAt) throw new Error("bootstrapTouched is raised after bootstrapRuntime");
if (callAt > depsAt) throw new Error("bootstrap must run before installDeps");
' || fail "acceptance: bootstrap must be the first owned mutating step"
log_step "Acceptance: bootstrap paths are declared once and are clean-host markers"
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must take bootstrap paths from the production profile"
grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/config/profile.ts \
|| fail "acceptance: the orchestrator symlink path must be declared in the profile"
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
log_step "Acceptance: clean-host is checked once, before the first mutation"
# Регрессия: preflight вызывался дважды и оба раза проверял clean-host.
# Ко второму разу на диске лежал собственный install-state.json, и каждая
# чистая установка падала сразу после apt-get.
! grep -rq 'cleanHostPhase' orchestrator/src \
|| fail "acceptance: the two-tier clean-host phase hack must not come back"
grep -q 'checkCleanHost' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must take clean-host as an explicit decision"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const enabled = source.split("checkCleanHost: true").length - 1;
const disabled = source.split("checkCleanHost: false").length - 1;
if (enabled !== 1) throw new Error("clean-host must be requested exactly once, got " + enabled);
if (disabled !== 1) throw new Error("the capabilities pass must opt out explicitly");
const at = source.indexOf("checkCleanHost: true");
const state = source.indexOf("await advanceInstallState(");
if (at > state) throw new Error("clean-host is checked after the first install-state write");
' || fail "acceptance: clean-host must be an entry condition, checked exactly once"
log_step "Acceptance: diagnostics never block rollback"
local command_file
for command_file in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'catch (diagnosticsError)' "$command_file" \
|| fail "acceptance: diagnostics must be best effort in $command_file"
done
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const file of [
["orchestrator/src/commands/install.ts", "await rollbackFailedInstall("],
["orchestrator/src/commands/reconfigure.ts", "await rollbackCurrentState()"]
]) {
const source = fs.readFileSync(file[0], "utf8");
const diagnostics = source.indexOf("await diagnosticsCollect(options)");
const guard = source.indexOf("catch (diagnosticsError)");
const rollback = source.indexOf(file[1]);
if (diagnostics < 0 || guard < 0 || rollback < 0) throw new Error("missing markers in " + file[0]);
if (!(diagnostics < guard && guard < rollback)) {
throw new Error("diagnostics is not guarded before rollback in " + file[0]);
}
}
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
log_step "Acceptance: persisting the failure state never blocks the rollback"
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
# падает ровно на заполненном диске и read-only ФС — там, где откат нужнее
# всего. Пока она стояла обычным await, её отказ уносил управление наружу
# мимо снятия firewall и остановки развёрнутых сервисов.
[ -f orchestrator/src/lib/rollback.ts ] \
|| fail "acceptance: модуль обязательного отката отсутствует"
local rollback_command
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'persistFailureState(' "$rollback_command" \
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
done
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, write] of [
["orchestrator/src/commands/install.ts", "await advanceInstallState("],
["orchestrator/src/commands/reconfigure.ts", "await markPhase("]
]) {
const source = fs.readFileSync(file, "utf8");
const handler = source.slice(source.indexOf("} catch (error) {"));
if (handler.length === 0) throw new Error("не найден обработчик ошибки в " + file);
if (handler.includes(write)) {
throw new Error("незащищённая запись состояния отказа в обработчике " + file);
}
const guarded = handler.indexOf("await persistFailureState(");
if (guarded < 0) throw new Error("состояние отказа не обёрнуто в " + file);
}
' || fail "acceptance: отказ записи состояния обязан продолжать откат, а не отменять его"
log_step "Acceptance: rollback stages are independent, not a cancellable chain"
# Каждая стадия отката — systemctl/cp/rm/nft, то есть умеет упасть сама.
# Цепочка `await` означала, что отказ первой отменяет все следующие: в
# reconfigure сервер оставался и с применённым сломанным firewall, и без
# восстановленных из /etc/hy2xs/backups конфигов одновременно.
grep -q 'export async function runRollbackStages' orchestrator/src/lib/rollback.ts \
|| fail "acceptance: у отката нет механизма независимых стадий"
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'await runRollbackStages(stages)' "$rollback_command" \
|| fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой"
done
# Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки.
#
# Инвариант здесь обратный тому, что стоял раньше. До появления
# runRollbackStages каждая команда несла `|| true` — это была единственная
# защита от того, что отказ одной оборвёт следующие. Теперь непрерывность
# обеспечивает стадийный раннер, а `|| true` стал маскировкой: стадия не
# могла сообщить, что восстановление на самом деле не выполнилось, и
# «restore configuration» никогда не попадала в список отказавших.
"$BUN_BIN" -e '
const fs = require("node:fs");
const checks = [
["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"],
["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"],
["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"],
["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "async function readNftablesServiceState"],
// Остановка rollback guard — та же категория. Скан её не покрывал,
// поэтому `systemctl stop ... || true` внутри stopRollbackTimer прожил
// дольше всех остальных заглушённых ошибок отката.
["orchestrator/src/steps/firewall.ts", "async function stopRollbackGuard", "export async function disarmFirewallRollback"],
// null как правая граница означает «до конца файла»: rollbackFirewallNow
// объявлена последней, и придумывать ей искусственный якорь означало бы
// ломать скан при каждой перестановке функций.
["orchestrator/src/steps/firewall.ts", "export async function rollbackFirewallNow", null]
];
for (const [file, from, to] of checks) {
const source = fs.readFileSync(file, "utf8");
const start = source.indexOf(from);
const end = to === null ? source.length : source.indexOf(to);
if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file);
const muted = source.slice(start, end).split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
if (muted.length) {
throw new Error(from + " в " + file + " скрывает ошибки:\n" + muted.join("\n"));
}
}
' || fail "acceptance: команды отката и резервного копирования обязаны сообщать о своих отказах"
log_step "Acceptance: rollback artifacts outlive the durable commit"
# Ошибка порядка фиксации. cancelFirewallRollback снимала таймер И удаляла
# резервные копии, а вызывалась ДО долговечной записи `phase: installed`.
# Отказ этой записи приводил в обработчик ошибки, обязательный откат честно
# запускался и сообщал «no HY2XS rollback markers found»: откатывать было
# уже нечем.
grep -q 'export async function disarmFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий"
grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию"
# Ищется ОБЪЯВЛЕНИЕ или ВЫЗОВ, то есть имя со скобкой, а не упоминание.
#
# Скан по голой подстроке был сломан с самого своего появления: комментарий у
# `disarmFirewallRollback` объясняет разделение, называя прежнюю функцию по
# имени, и гейт падал бы на нём. Не падал он только потому, что до этого шага
# сборка не доходила — более ранние гейты останавливали её раньше.
! grep -rEq 'cancelFirewallRollback[[:space:]]*\(' orchestrator/src \
|| fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха"
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, installed] of [
["orchestrator/src/commands/install.ts", "await advanceInstallState(context, ownership, \"installed\")"],
["orchestrator/src/commands/reconfigure.ts", "await markPhase(context, \"installed\")"]
]) {
const source = fs.readFileSync(file, "utf8");
const disarm = source.indexOf("disarmFirewallRollback(context)");
const commit = source.indexOf(installed);
const cleanup = source.indexOf("cleanupFirewallRollback(context)");
if (disarm < 0 || commit < 0 || cleanup < 0) throw new Error("не найдены шаги фиксации в " + file);
if (!(disarm < commit && commit < cleanup)) {
throw new Error("порядок обязан быть disarm -> durable installed -> cleanup в " + file);
}
}
' || fail "acceptance: данные отката обязаны переживать долговечную фиксацию успеха"
# Восстановление firewall не имеет права удалить копии, не восстановив.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("export async function rollbackFirewallNow"));
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
if (failures < 0 || preserved < 0 || cleanup < 0) throw new Error("не найдены ветки восстановления");
if (!(failures < preserved && preserved < cleanup)) {
throw new Error("резервные копии удаляются раньше проверки успеха восстановления");
}
' || fail "acceptance: копии удаляются только после подтверждённого восстановления firewall"
log_step "Acceptance: reconfigure backups are operation-scoped and proven"
# Копии всех операций лежали одним общим набором *.bak. При неудачном
# копировании в операции B её откат восстанавливал файлы, сохранённые
# операцией A, — возвращал сервер в более старое состояние и выглядел
# успешным.
[ -f orchestrator/src/lib/backupManifest.ts ] \
|| fail "acceptance: манифест резервной копии отсутствует"
grep -q 'backupDir(opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: копия reconfigure снова не привязана к операции"
grep -q 'parseManifest(await readText(path), opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: откат читает копию без проверки принадлежности операции"
! grep -qF '/etc/hy2xs/backups/config.yaml.bak' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: вернулся общий набор *.bak, смешивающий операции"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const backup = source.indexOf("await backupCurrentState(context)");
for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) {
const at = source.indexOf(mutation);
if (at < 0) throw new Error("не найден шаг " + mutation);
if (backup > at) throw new Error("копия снимается после мутации " + mutation);
}
const verify = source.indexOf("await fileExists(target)");
if (verify < 0) throw new Error("создание копии не проверяется");
' || fail "acceptance: полная проверенная копия обязана предшествовать первой мутации"
log_step "Acceptance: the install-state directory entry is durable too"
# writeTextAtomic синхронизирует файл и его каталог. Но при первой установке
# /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib
# остаётся несинхронизированной: после потери питания мог исчезнуть весь
# каталог вместе с маркером.
grep -q 'return { created: !existed }' orchestrator/src/lib/fs.ts \
|| fail "acceptance: ensureDir не сообщает о фактическом создании каталога"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function ensureDir");
const body = source.slice(start, source.indexOf("export async function writeTextAtomic"));
if (!/if \(!existed\) \{\s*\n\s*await syncDirectory\(dirname\(path\)\)/.test(body)) {
throw new Error("родительский каталог не синхронизируется при создании");
}
' || fail "acceptance: создание каталога маркера обязано быть долговечным"
log_step "Acceptance: doctor never triggers a peer state write over HTTP"
# Guard защищает только процесс оркестратора и не способен запретить побочный
# эффект в другом процессе. Успешная machine-auth заставляет админку
# выполнить UPDATE peer.last_connection_at, поэтому проба с ВАЛИДНЫМ паролем
# ограничена режимом install, а doctor работает в режиме reconfigure.
"$BUN_BIN" -e '
const fs = require("node:fs");
const smoke = fs.readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const guard = smoke.indexOf("if (context.mode === \"install\")");
const validAuth = smoke.indexOf("\"auth valid credentials\"");
const readsSecret = smoke.indexOf("cut -d= -f2-");
if (guard < 0 || validAuth < 0 || readsSecret < 0) throw new Error("не найдена проба успешной авторизации");
if (validAuth < guard || readsSecret < guard) {
throw new Error("проба с валидными учётными данными вышла за пределы режима install");
}
if (smoke.split("\"auth valid credentials\"").length - 1 !== 1) {
throw new Error("проб успешной авторизации больше одной");
}
const doctor = fs.readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
if (!doctor.includes("mode: \"reconfigure\"") || doctor.includes("mode: \"install\"")) {
throw new Error("doctor работает в режиме, который запускает пробу успешной авторизации");
}
' || fail "acceptance: диагностика не имеет права менять состояние пира через HTTP"
log_step "Acceptance: the install-state marker has exactly one durable writer"
# Раньше writeText в install и writeTextAtomic в reconfigure давали одному
# файлу две разные гарантии, причём слабейшую — команде, которая его создаёт.
[ -f orchestrator/src/lib/installStateWriter.ts ] \
|| fail "acceptance: модуль записи маркера установки отсутствует"
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'await persistInstallState(record)' "$rollback_command" \
|| fail "acceptance: $rollback_command пишет маркер установки мимо единственного писателя"
! grep -q 'writeText(INSTALL_STATE_PATH' "$rollback_command" \
|| fail "acceptance: неатомарная перезапись маркера установки вернулась в $rollback_command"
done
# Долговечность, а не только атомарность: rename без fsync после потери
# питания штатно отдаёт нулевой файл, а маркер — это метаданные восстановления.
grep -q 'await handle.sync()' orchestrator/src/lib/fs.ts \
|| fail "acceptance: временный файл подставляется без fsync данных"
grep -q 'async function syncDirectory' orchestrator/src/lib/fs.ts \
|| fail "acceptance: каталог не синхронизируется после подстановки"
# Владелец обязан выставляться ДО подстановки: иначе существует окно, в
# котором файл уже виден по целевому пути с чужими правами.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function writeTextAtomic");
if (start < 0) throw new Error("writeTextAtomic отсутствует");
const body = source.slice(start);
const chown = body.indexOf("chownByName(tmp");
const sync = body.indexOf("await handle.sync()");
const rename = body.indexOf("await rename(tmp, path)");
if (chown < 0 || sync < 0 || rename < 0) throw new Error("не найдены шаги атомарной записи");
if (!(chown < sync && sync < rename)) {
throw new Error("порядок обязан быть: права/владелец -> fsync -> rename");
}
' || fail "acceptance: атомарная запись обязана выставлять владельца и синхронизировать до подстановки"
# Каждый production-вызов обязан объявлять владельца явно: параметр
# необязателен только ради тестов, которые пишут во временный каталог.
"$BUN_BIN" -e '
const fs = require("node:fs");
const path = require("node:path");
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.name.endsWith(".ts")) continue;
const source = fs.readFileSync(full, "utf8");
// Ищутся ВЫЗОВЫ, а не объявление: у самой функции параметр owner
// необязателен, потому что тесты пишут во временный каталог, где
// выставить root:root нельзя.
let at = source.indexOf("await writeTextAtomic(");
while (at >= 0) {
const call = source.slice(at, at + 400);
if (!call.includes("owner:")) offenders.push(full);
at = source.indexOf("await writeTextAtomic(", at + 1);
}
}
};
walk("orchestrator/src");
if (offenders.length) {
throw new Error("вызовы без владельца: " + [...new Set(offenders)].join(", "));
}
' || fail "acceptance: production-запись обязана объявлять владельца файла"
log_step "Acceptance: reconfigure classifies by ownership and typed guard state, not by message text"
# Обычные операционные отказы классифицируются по ВЛАДЕНИЮ. Исключение ровно
# одно, и оно типизированное: сработавший guard по владению неотличим от
# обычного отказа smoke — тронут firewall, перезапущены сервисы, — но чинить
# надо другое, потому что сервер уже вернулся на ПРЕЖНИЙ firewall. Поэтому он
# опознаётся по типу ошибки и никогда по её тексту.
#
# Гейт требовал литеральное `classifyReconfigureFailure(ownership)` и потому
# утверждал не тот контракт, который назван в его же заголовке: второй
# параметр появился вместе с типизированным распознаванием guard, и с этого
# момента проверка описывала предыдущую версию архитектуры.
#
# `error instanceof FirewallGuardFiredError` здесь намеренно НЕ дублируется:
# тот же инвариант проверяется ниже, в «a fired guard forbids the durable
# commit», и для install, и для reconfigure. Один инвариант — один гейт.
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
grep -q 'classifyReconfigureFailure(ownership, error)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure failure classification must use ownership and the typed operation error"
}
# Каналы утечки секретов: Config API и журналы.
run_secret_channel_acceptance() {
log_step "Acceptance: config API is an allowlist, not a denylist"
grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \
|| fail "acceptance: config reads must go through the allowlist"
grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \
|| fail "acceptance: config writes must go through the allowlist"
# Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ
# getConfig — он проверяет, что маршрут не вернулся.
! grep -rq 'controller.GetConfig' apps/router \
|| fail "acceptance: the arbitrary-key getConfig route came back"
! grep -q 'func GetConfig(' apps/controller/config.go \
|| fail "acceptance: the arbitrary-key getConfig handler came back"
! grep -rq 'export function getConfigApi' apps/frontend/src \
|| fail "acceptance: the frontend client for getConfig came back"
! grep -rq 'getConfigApi(' apps/frontend/src \
|| fail "acceptance: something still calls the removed getConfig client"
local secret_key
for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do
grep -q "${secret_key}," apps/model/constant/config.go \
|| fail "acceptance: ${secret_key} must be declared an internal config key"
done
# Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8");
const start = source.indexOf("publicReadableConfigKeys");
const end = source.indexOf("func IsPublicReadableConfigKey");
const body = source.slice(start, end);
for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) {
if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist");
}
' || fail "acceptance: no secret key may appear in the public config allowlist"
log_step "Acceptance: request logging never carries query values"
# Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер
# больше не пишет RequestURI, обязан быть разрешён. Поэтому строки
# комментариев отбрасываются до поиска.
local log_code server_code
log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)"
server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)"
grep -q 'c.Request.URL.Path' <<<"$log_code" \
|| fail "acceptance: the request logger must log the path, not RequestURI"
! grep -q 'RequestURI' <<<"$log_code" \
|| fail "acceptance: RequestURI carries the machine token in its query string"
! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \
|| fail "acceptance: the reqUri log field came back"
log_step "Acceptance: the admin has exactly one HTTP log channel"
! grep -q 'gin.Default()' <<<"$server_code" \
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
grep -q 'gin.New()' <<<"$server_code" \
|| fail "acceptance: the admin engine must be built with gin.New()"
grep -q 'gin.Recovery()' <<<"$server_code" \
|| fail "acceptance: gin.New() must still install the recovery middleware"
log_step "Acceptance: exported logs are sanitized on both sides"
grep -q 'SanitizeLogText' apps/service/journal.go \
|| fail "acceptance: exported Hysteria journal must be sanitized"
grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \
|| fail "acceptance: diagnostics bundle must sanitize collected journals"
grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \
|| fail "acceptance: the admin journal must be part of the sanitized set"
log_step "Acceptance: machine token comparison is constant time"
grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \
|| fail "acceptance: the machine token must be compared in constant time"
}
# Импорт пиров: одна транзакция и ровно один JSON-документ.
run_atomic_import_acceptance() {
log_step "Acceptance: peer import is a single database transaction"
grep -q 'WithPeerTx' apps/service/peer.go \
|| fail "acceptance: peer import must apply the whole batch in one transaction"
grep -q 'func WithPeerTx' apps/dao/peer.go \
|| fail "acceptance: the peer transaction boundary is missing from the dao layer"
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 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);
}
' || fail "acceptance: peer import must not bypass the transaction"
log_step "Acceptance: peer import accepts exactly one JSON document"
grep -q 'io.EOF' apps/controller/peer.go \
|| fail "acceptance: peer import must verify that nothing follows the JSON document"
grep -q 'exactly one JSON document' apps/controller/peer.go \
|| fail "acceptance: the trailing-document refusal must be explicit"
log_step "Acceptance: peer export offers both modes in the UI"
grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \
|| fail "acceptance: the UI must be able to request a secrets-bearing backup"
grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \
|| fail "acceptance: the backup export button is missing"
grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \
|| fail "acceptance: a secrets-bearing export must require explicit confirmation"
local locale
for locale in ru en; do
grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \
|| fail "acceptance: the backup warning is missing from the ${locale} locale"
done
}
# Отзыв доступа: обе половины официального контракта Hysteria и fail-closed
# ограничение устройств.
run_access_revocation_acceptance() {
log_step "Acceptance: disabling a peer also terminates its live session"
# Запись `disabled=1` закрывает только БУДУЩИЕ обращения к HTTP-auth:
# установленная QUIC-сессия сама по себе не рвётся. Официальная документация
# описывает `/kick` и блокировку в auth backend как пару — по отдельности не
# работает ни одна половина.
code_has apps/service/hysteria2_api.go -F -- 'func disconnectAuthIDs' \
|| fail "acceptance: the session disconnect primitive is missing"
code_has apps/service/peer.go -F -- 'func reconcileLiveSessions' \
|| fail "acceptance: revoking access must go through a single disconnect path"
log_step "Acceptance: the Traffic Stats /kick has exactly one caller"
# Пока обращений к `/kick` было два — в отзыве доступа и в cron, — они
# расходились: у cron не было ни дедупликации, ни разбиения на части, зато
# был POST с пустым массивом каждые 30 секунд. Один вход в Hysteria делает
# такое расхождение невозможным.
"$BUN_BIN" -e '
const fs = require("node:fs");
const offenders = [];
for (const file of ["apps/service/peer.go", "apps/service/cron.go", "apps/service/peer_import.go"]) {
if (!fs.existsSync(file)) continue;
const source = fs.readFileSync(file, "utf8")
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (source.includes("KickUsers(")) offenders.push(file);
}
if (offenders.length) {
throw new Error("the Traffic Stats /kick is reached outside disconnectAuthIDs: " + offenders.join(", "));
}
' || fail "acceptance: production /kick must go through disconnectAuthIDs only"
log_step "Acceptance: session disconnect does not write peer state"
# Прежний Hysteria2Kick вместе с разрывом проставлял `banned_until`, поэтому
# им нельзя было воспользоваться для отключения пира: операция записала бы
# заодно временную блокировку — другой механизм с другим сроком жизни.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func disconnectAuthIDs");
if (start < 0) throw new Error("disconnectAuthIDs is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
for (const forbidden of ["banned_until", "disabled", "dao.UpdatePeer(", "dao.GetPeer("]) {
if (body.includes(forbidden)) {
throw new Error("disconnectAuthIDs touches peer state: " + forbidden);
}
}
' || fail "acceptance: the disconnect primitive must not write peer state"
log_step "Acceptance: the access policy is declared once"
# Правило доступа существовало в двух экземплярах — SQL-условием в
# Hysteria2Auth и другим SQL-условием в cron, — и расходилось ровно на
# границах: `quota == usage`, `quota == 0`, `now == expiresAt`,
# `now == bannedUntil`. Пир с исчерпанной квотой не пускался заново, но его
# живая сессия не разрывалась никогда.
code_has apps/service/peer_access.go -F -- 'func peerAccessDenied' \
|| fail "acceptance: the single access predicate is missing"
"$BUN_BIN" -e '
const fs = require("node:fs");
const offenders = [];
for (const file of ["apps/service/hysteria2_api.go", "apps/service/cron.go"]) {
const source = fs.readFileSync(file, "utf8")
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Колонки политики доступа не имеют права появляться в запросах: решение
// принимает peerAccessDenied, а не выборка.
for (const column of ["quota_bytes", "expires_at", "banned_until"]) {
if (source.includes(column)) offenders.push(file + ":" + column);
}
}
if (offenders.length) {
throw new Error("the access policy is back inside SQL: " + offenders.join(", "));
}
' || fail "acceptance: the access policy must live in peerAccessDenied, not in SQL"
log_step "Acceptance: the account cron job belongs to the scheduler"
# `CronHandleAccount -> go func() -> go saveAccountTraffic; go kickAccount`
# заканчивалась для планировщика почти мгновенно, поэтому StopCron не ждал
# настоящей работы: releaseResource закрывал SQLite, а горутины продолжали
# писать в закрытое соединение.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/cron.go", "utf8");
const start = source.indexOf("func CronHandleAccount");
if (start < 0) throw new Error("CronHandleAccount is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (/\bgo\s+(func|[A-Za-z_])/.test(body)) {
throw new Error("the account cron job still detaches its work into goroutines");
}
' || fail "acceptance: the account cron job must run synchronously"
log_step "Acceptance: systemd opinion drives no cron or access decision"
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
! code_has apps/service/cron.go -E -- 'Hysteria2(IsRunning|ServiceState)\(' \
|| fail "acceptance: the account cron job must not gate on the systemd state"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func Hysteria2Auth");
if (start < 0) throw new Error("Hysteria2Auth is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (/Hysteria2(IsRunning|ServiceState)\(|hysteriaServiceState\(/.test(body)) {
throw new Error("auth gates on the systemd state");
}
' || fail "acceptance: auth must not gate on the systemd state"
log_step "Acceptance: concurrent auth cannot exceed the device limit"
# Между чтением `/online` и ответом «allow» место ничем не удерживалось:
# два одновременных запроса при `online = max-1` получали разрешение оба.
code_has apps/service/peer_admission.go -F -- 'func reserveDeviceSlot' \
|| fail "acceptance: the device admission tracker is missing"
code_has apps/service/hysteria2_api.go -F -- 'reserveDeviceSlot(' \
|| fail "acceptance: auth does not reserve a device slot"
log_step "Acceptance: the online snapshot cannot be reordered against admission"
# Учёта выданных разрешений одного НЕДОСТАТОЧНО. Сетевой запрос выполнялся
# вне мьютекса, поэтому снимки приходили в резервацию в произвольном порядке,
# и более старый откатывал `lastOnline` назад:
#
# A получил разрешение при online = 0
# C обработал online = 1 первым: разрешение A признано проявившимся
# B пришёл со своим устаревшим 0 -> место снова «свободно» -> ALLOW
#
# Детектор гонок здесь молчит принципиально: вся работа с памятью защищена
# мьютексом, гонка логическая. Единственная защита — замок по authID вокруг
# ВСЕЙ последовательности «прочитать /online -> занять место».
code_has apps/service/peer_admission.go -F -- 'func lockPeerAdmission' \
|| fail "acceptance: the per-peer admission gate is missing"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func Hysteria2Auth");
if (start < 0) throw new Error("Hysteria2Auth is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
const gate = body.indexOf("lockPeerAdmission(");
const online = body.indexOf("hysteria2Online()");
const reserve = body.indexOf("reserveDeviceSlot(");
if (gate < 0) throw new Error("auth does not take the admission gate");
if (online < 0 || reserve < 0) throw new Error("auth no longer reads /online or reserves a slot");
// Замок обязан быть взят ДО чтения статистики: взятый после него он
// защищал бы только резервацию, а переупорядочиваются именно снимки.
if (gate > online) throw new Error("the admission gate is taken after the /online read");
// Освобождение проверяется ИМЕННО ЭТОГО замка.
//
// Здесь стояло `/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь
// отложенный вызов». Такой гейт пережил бы
//
// unlockAdmission := lockPeerAdmission(...)
// defer someOtherCleanup()
//
// — то есть замок, который не отпускается никогда. Имя переменной берётся
// из самого присваивания, поэтому её переименование гейт не ломает.
const binding = body.match(/(\w+)\s*:=\s*lockPeerAdmission\(/);
if (!binding) throw new Error("the admission gate result is not bound to a variable");
if (!new RegExp("defer\\s+" + binding[1] + "\\(\\)").test(body)) {
throw new Error("the admission gate is never released");
}
// Замок именно по authId. Литеральный ключ означал бы один замок на
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
throw new Error("the admission gate is not keyed by the peer auth id");
}
' || fail "acceptance: the /online read and the reservation must be one serialized sequence"
log_step "Acceptance: cron reconciles every live session, not only known peers"
# Прежний обход шёл по НАЙДЕННЫМ пирам, поэтому authID, которого нет в базе,
# молча выпадал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку.
# Так после неудавшегося второго шага удаления или импорта, заменившего
# `auth_id`, живая сессия оставалась навсегда — восстановить её
# переподключением уже нельзя, авторизация нового значения не знает.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/cron.go", "utf8");
const start = source.indexOf("func enforcePeerAccess");
if (start < 0) throw new Error("enforcePeerAccess is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Обход идёт по идентификаторам из `/online`, а не по строкам выборки.
if (!/for\s+_,\s*authID\s*:=\s*range\s+authIDs\s*\{/.test(body)) {
throw new Error("enforcePeerAccess no longer walks the online auth ids");
}
// Мало отличить сессию без строки в базе — её надо ЗАВЕРШИТЬ. Прежний код
// ровно на этом месте делал молчаливый пропуск, поэтому ветка проверяется
// по содержимому, а не по факту существования.
const orphan = body.indexOf("if !found {");
if (orphan < 0) {
throw new Error("enforcePeerAccess does not distinguish a session without a peer row");
}
let depth = 0;
let orphanEnd = orphan;
for (let i = body.indexOf("{", orphan); i < body.length; i++) {
if (body[i] === "{") depth++;
else if (body[i] === "}") {
depth--;
if (depth === 0) { orphanEnd = i; break; }
}
}
if (!/kick\s*=\s*append\(/.test(body.slice(orphan, orphanEnd + 1))) {
throw new Error("a live session without a peer row is skipped instead of terminated");
}
if (!body.includes("peerSessionNeedsReconcile(")) {
throw new Error("enforcePeerAccess does not apply the live-session predicate");
}
// Отказ базы не даёт права рвать сессии: недоступная SQLite иначе
// отключила бы всех подключённых пиров сразу.
const lookup = body.indexOf("dao.ListPeer(");
const kickCall = body.indexOf("disconnectAuthIDs(");
if (lookup < 0 || kickCall < 0) throw new Error("enforcePeerAccess lost its lookup or its disconnect");
const between = body.slice(lookup, kickCall);
if (!/if\s+err\s*!=\s*nil\s*\{\s*return\s+err/.test(between)) {
throw new Error("a storage failure no longer stops enforcement before /kick");
}
if (!body.includes("sweepDeviceAdmissions(")) {
throw new Error("the admission tracker is never swept against the real online picture");
}
' || fail "acceptance: cron must reconcile every live session reported by /online"
log_step "Acceptance: the live-session predicate does not duplicate the access policy"
# peerAccessDenied остаётся единственным владельцем disabled/quota/expiry/ban.
# Своего у предиката ровно одно — число подключённых устройств, которого в
# хранимом состоянии пира нет.
code_has apps/service/peer_access.go -F -- 'func peerSessionNeedsReconcile' \
|| fail "acceptance: the live-session predicate is missing"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer_access.go", "utf8");
const start = source.indexOf("func peerSessionNeedsReconcile");
if (start < 0) throw new Error("peerSessionNeedsReconcile is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (!body.includes("peerAccessDenied(")) {
throw new Error("the live-session predicate does not reuse the access policy");
}
for (const field of ["Disabled", "QuotaBytes", "DownloadBytes", "UploadBytes", "ExpiresAt", "BannedUntil"]) {
if (body.includes("peer." + field)) {
throw new Error("the access policy is duplicated inside the live-session predicate: " + field);
}
}
' || fail "acceptance: the live-session predicate must not restate peerAccessDenied"
log_step "Acceptance: deleting a peer revokes access before removing the row"
# `return dao.DeletePeer(...)` убирал строку вместе с auth_id — то есть
# вместе с единственным, чем можно было бы завершить живую сессию.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const start = source.indexOf("func DeletePeer");
if (start < 0) throw new Error("DeletePeer is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
const reconcile = body.indexOf("reconcileLiveSessions(");
const remove = body.indexOf("dao.DeletePeer(");
if (reconcile < 0) throw new Error("DeletePeer does not terminate live sessions");
if (remove < 0) throw new Error("DeletePeer does not remove the row");
if (reconcile > remove) throw new Error("DeletePeer removes the row before terminating the session");
' || fail "acceptance: peer deletion must terminate the session before dropping the row"
log_step "Acceptance: peer import reconciles live sessions after commit"
# Импорт переписывает credential- и access-состояние целиком, включая
# auth_id. Старое значение существует только ДО commit, а разрывать сессию
# можно только ПОСЛЕ него.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const start = source.indexOf("func UpsertPeerExport");
if (start < 0) throw new Error("UpsertPeerExport is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
const tx = body.indexOf("dao.WithPeerTx(");
const reconcile = body.indexOf("reconcileLiveSessions(");
if (reconcile < 0) throw new Error("peer import never reconciles live sessions");
if (tx < 0 || reconcile < tx) throw new Error("peer import reconciles before the transaction");
' || fail "acceptance: peer import must reconcile live sessions after commit"
log_step "Acceptance: delete and import report structured service errors"
# Обе операции умеют завершиться ЧАСТИЧНО — состояние применено, живую
# сессию завершить не удалось. Через vo.Fail такой исход уезжал бы панели
# неотличимо от полного отказа.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/peer.go", "utf8");
for (const [handler, call] of [["func DeletePeer", "service.DeletePeer("], ["func ImportPeer", "service.UpsertPeerExport("]]) {
const start = source.indexOf(handler);
if (start < 0) throw new Error(handler + " is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
if (!body.includes(call)) throw new Error(handler + " no longer calls " + call);
if (!body.includes("failService(err, c)")) {
throw new Error(handler + " reports a partial result as a plain failure");
}
}
' || fail "acceptance: delete and import must use the structured service error path"
log_step "Acceptance: a partial revocation is reported by code, not by prose"
# Долговременная запись к этому моменту уже применена и НЕ откатывается:
# достигнутое безопасное состояние нельзя отменять из-за неудачи второго
# шага. Без отдельного кода оператор прочитал бы обычную ошибку как
# «не сработало, состояние прежнее».
code_has apps/model/constant/error.go -F -- 'ErrCodePeerDisconnectFailed' \
|| fail "acceptance: the partial-revocation reason code is missing"
code_has apps/service/peer_errors.go -F -- 'func PeerDisconnectError' \
|| fail "acceptance: the partial-revocation error is missing from the service layer"
code_has apps/controller/peer.go -F -- 'failService(err, c)' \
|| fail "acceptance: peer actions must map domain reasons to response codes"
code_has apps/frontend/src/utils/api-error.ts -F -- 'peer_disconnect_failed' \
|| fail "acceptance: the panel does not know the partial-revocation code"
local locale
for locale in ru en; do
code_has "apps/frontend/src/lang/package/${locale}.ts" -F -- 'peer_disconnect_failed' \
|| fail "acceptance: the partial-revocation phrase is missing from the ${locale} locale"
done
log_step "Acceptance: the device limit is fail-closed"
# Отказ Traffic Stats API превращал объявленный «Лимит устройств: 3» в
# безлимит, и узнать об этом можно было только по строке WARN в журнале.
# Форма старой дыры: возврат успеха авторизации из ветки ошибки /online.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func Hysteria2Auth");
if (start < 0) throw new Error("Hysteria2Auth is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
const code = body
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (!code.includes("hysteria2Online()")) {
throw new Error("auth no longer asks the Traffic Stats API directly");
}
// Терпимый путь отдаёт ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечает
// «служба неактивна», — а этот ответ не отличается от «спросить не
// удалось». На пути принятия решения о доступе ему не место.
if (/[^a-z0-9_]Hysteria2Online\(\)/.test(code)) {
throw new Error("auth takes the display-tolerant online path");
}
// Старая дыра имела конкретную форму: возврат УСПЕХА изнутри ветки
// обработки ошибки.
//
// onlineUsers, err := Hysteria2Online()
// if err != nil {
// logrus.WithError(err).Warn(...)
// return *peer.Id, *peer.AuthId, nil
// }
//
// Здесь стояла регулярка `err != nil \{[\s\S]*?return \*peer\.Id`, и она
// была неверна: ленивый `[\s\S]*?` свободно пересекает границы блоков,
// поэтому она срабатывала на ЛЮБОЙ функции, где после какой-нибудь
// проверки ошибки где-то ниже стоит успешный возврат, — то есть на
// правильном коде тоже. Гейт, который падает на корректной реализации,
// не проверяет ничего: его нельзя удовлетворить, не сломав продукт.
//
// Тело ветки выделяется по балансу фигурных скобок — тогда «внутри
// ветки» действительно означает внутри ветки.
const failureBranches = [];
for (let at = code.indexOf("if err != nil {"); at >= 0; at = code.indexOf("if err != nil {", at + 1)) {
let depth = 0;
let end = at;
for (let i = code.indexOf("{", at); i < code.length; i++) {
if (code[i] === "{") depth++;
else if (code[i] === "}") {
depth--;
if (depth === 0) { end = i; break; }
}
}
failureBranches.push(code.slice(at, end + 1));
}
for (const branch of failureBranches) {
if (/return \*peer\.Id/.test(branch)) {
throw new Error("auth still returns success from an error branch");
}
}
' || fail "acceptance: the device limit must be fail-closed"
code_has apps/service/hysteria2.go -F -- 'var hysteriaServiceState' \
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
log_step "Acceptance: the public endpoint error names no transport"
# Публичный endpoint Hysteria — UDP/QUIC, и «TCP port» закладывал в
# сообщение заведомо ложную семантику.
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|| fail "acceptance: the public port error must not claim a TCP transport"
log_step "Acceptance: rotating a credential rotates the session identity"
# Отзыв секрета состоит из двух шагов — записать новый digest и завершить
# старые сессии. Второй шаг может не удаться, и это нормально: сходимость
# обязан обеспечить cron. Но сверять ему было нечем, пока `auth_id`
# оставался прежним: сессия по отозванному секрету выглядела законной по
# всем признакам.
#
# Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Hysteria
# регистрирует соединение в Traffic Stats API только ПОСЛЕ возврата
# backend-auth (app/v2.12.2), поэтому `/kick`, прошедший в это окно, его не
# видит. Атомарной пары «решение авторизации + регистрация онлайна» upstream
# не даёт, и повторное чтение базы перед ответом окно не закрывает.
code_has apps/service/peer_secret.go -F -- 'func newPeerAuthID' \
|| fail "acceptance: the single auth_id generator is missing"
code_has apps/service/peer_secret.go -F -- 'func credentialGenerationChanged' \
|| fail "acceptance: the credential generation rule is missing"
# Генератор ровно один: `util.RandomString(18)` россыпью по местам создания
# означал бы, что ротация и создание умеют разойтись.
# Область скана — только исходники Go: `apps` целиком втянул бы
# node_modules и dist фронтенда.
local auth_id_generators
auth_id_generators="$(code_mentions_in 'RandomString(18)' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
[ -z "$auth_id_generators" ] \
|| fail "acceptance: auth_id is generated outside newPeerAuthID: $auth_id_generators"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const bodyOf = (name) => {
const start = source.indexOf(name);
if (start < 0) throw new Error(name + " is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
return (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
};
// Форма записи: новая идентичность попадает в ТУ ЖЕ операцию, что и новый
// digest, — иначе между ними появляется состояние «секрет уже сменён,
// сессии ещё старые», которое ничем не отличить от законного.
for (const fn of ["func UpdatePeer", "func applyPeerImportEntry"]) {
const body = bodyOf(fn);
if (!body.includes("credentialGenerationChanged(")) {
throw new Error(fn + " does not check the credential generation");
}
if (!/updates\["auth_id"\]\s*=/.test(body)) {
throw new Error(fn + " does not rotate the session identity with the secret");
}
}
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
// сессию. Разрыв по новому не завершил бы ничего.
const update = bodyOf("func UpdatePeer");
if (!update.includes("reconcileLiveSessions([]string{authIDOf(before)})")) {
throw new Error("UpdatePeer no longer disconnects the previous session identity");
}
' || fail "acceptance: a rotated secret must not keep the old session identity"
log_step "Acceptance: the Traffic Stats API address is one contract"
# Оркестратор принимал любой IPv4, шаблон честно его подставлял, а проверка
# профиля сверяла конфиг с тем же значением: все гейты проходили, а продукт
# переставал работать — админка читает из `trafficStats.listen` только порт
# и всегда идёт на loopback.
code_has orchestrator/src/config/env.ts -F -- 'TRAFFIC_STATS_HOST is fixed' \
|| fail "acceptance: the orchestrator no longer fixes the Traffic Stats host"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
const start = source.indexOf("export function validateRuntimeConfig");
if (start < 0) throw new Error("validateRuntimeConfig is missing");
const body = source.slice(start);
if (!/config\.hysteriaTrafficStatsHost\s*!==\s*"127\.0\.0\.1"/.test(body)) {
throw new Error("the Traffic Stats host is not pinned to loopback");
}
' || fail "acceptance: HY2XS_HYSTERIA_TRAFFIC_STATS_HOST must be pinned to 127.0.0.1"
grep -q '^HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127\.0\.0\.1$' package/config/hy2xs.env \
|| fail "acceptance: the packaged env no longer pins the Traffic Stats host"
# Вторая половина контракта: админка не подставляет loopback молча вместо
# прочитанного адреса, а называет расхождение.
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently"
# Проверяется СЕМАНТИКА, а не наличие функции.
#
# Первая версия принимала любой `ip.IsLoopback()`, и это неверно: слушатель на
# конкретном адресе принимает соединения только на него, поэтому
# `bind 127.0.0.5:36712` + `dial 127.0.0.1:36712` даёт connection refused.
# Такой адрес выглядит «локальным», а control plane при нём уже не работает —
# то есть отказ авторизации всем пирам без единой подсказки о причине.
! code_has apps/service/config.go -F -- 'IsLoopback()' \
|| fail "acceptance: the Traffic Stats host check is back to any loopback address"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/config.go", "utf8");
const start = source.indexOf("func assertTrafficStatsHostReachable");
if (start < 0) throw new Error("assertTrafficStatsHostReachable is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Принимается ровно три формы: канон, IPv4-wildcard и пустой хост (тот же
// wildcard). Всё остальное, включая прочие адреса loopback, недостижимо.
for (const accepted of [`"127.0.0.1"`, `"0.0.0.0"`]) {
if (!body.includes(accepted)) {
throw new Error("the reachable host " + accepted + " is no longer accepted explicitly");
}
}
' || fail "acceptance: the Traffic Stats host must be matched exactly"
# Негативный случай закреплён тестом: без него правило выглядит произвольным
# ужесточением, и следующий читатель вернёт IsLoopback обратно.
code_has apps/service/config_traffic_stats_test.go -F -- '127.0.0.5' \
|| fail "acceptance: the unreachable loopback address is not pinned by a test"
log_step "Acceptance: the systemd state has three values, not two"
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
# отвечает словом состояния в stdout ВМЕСТЕ с кодом 3. Пока другого
# примитива не было, «служба остановлена» и «спросить не удалось» приходили
# в панель одним значением.
code_has apps/util/linux.go -F -- 'func ExecProbe' \
|| fail "acceptance: the exit-code aware probe is missing"
code_has apps/service/hysteria2.go -F -- 'HysteriaServiceUnknown' \
|| fail "acceptance: the unknown service state is missing"
! code_has apps/service/hysteria2.go -F -- 'is-active --quiet' \
|| fail "acceptance: --quiet suppresses the very output the probe needs"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func Hysteria2Online");
if (start < 0) throw new Error("Hysteria2Online is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Ярлык «служба неактивна -> пусто, ошибки нет» делал недоступность
// Traffic Stats API неотличимой от «никто не подключён»: сборщик метрик
// выставлял apiReachable = true, ни разу не сходив в API.
if (/hysteria2IsRunning\(\)|HysteriaServiceState\(\)/.test(body)) {
throw new Error("the display path still derives the online picture from systemd");
}
if (/return\s+map\[string\]int64\{\}\s*,\s*nil/.test(body)) {
throw new Error("the display path still invents an empty online picture");
}
' || fail "acceptance: the online picture must come from the Traffic Stats API"
code_has apps/model/vo/dashboard.go -E -- 'ServiceState +string' \
|| fail "acceptance: the dashboard no longer reports the service state separately"
code_has apps/model/vo/peer.go -F -- 'PeerOnlineStateUnavailable' \
|| fail "acceptance: the peer list cannot report an unknown online state"
! code_has apps/service/peer.go -E -- 'onlineUsers,\s*_\s*:=' \
|| fail "acceptance: the peer list swallows the Traffic Stats failure again"
log_step "Acceptance: the Hysteria journal is parsed in its actual format"
# JSON-логгер Hysteria 2.12.2 пишет `time` числом (EpochMillisTimeEncoder,
# причём дробным), поэтому разбор в структуру со строковым полем падал на
# КАЖДОЙ строке и уходил в fallback: `HYSTERIA_LOG_FORMAT=json` был включён,
# а структурой никто не пользовался.
code_has apps/service/journal.go -F -- 'func hysteriaLogTime' \
|| fail "acceptance: the journal no longer understands the upstream time format"
code_has apps/service/journal.go -F -- 'func hysteriaLogContext' \
|| fail "acceptance: the structured context of the journal is dropped"
! code_has apps/service/journal.go -E -- 'json\.Unmarshal\(\[\]byte\(.*\), &parsed\)' \
|| fail "acceptance: the journal record is parsed straight into the display type again"
log_step "Acceptance: production does not run the upstream update check"
# Версией Hysteria владеет versions.env -> сборка -> пакет -> оркестратор.
# Параллельная проверка обновлений ничего не обновляет, ходит наружу при
# каждом старте и отличает production от тестового окружения, где она уже
# выключена.
grep -q '^Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1$' package/systemd/hysteria-server.service \
|| fail "acceptance: the hysteria unit does not disable the upstream update check"
log_step "Acceptance: the config screen shows the file, not a rendering of it"
# Панель накладывала ответ сервера на полный объект дефолтов, поэтому
# отсутствующая секция trafficStats показывалась как `:9999`. Экран,
# существующий ради диагностики дрейфа, этот дрейф скрывал.
code_has apps/service/hysteria2_profile.go -F -- 'func BuildHysteria2Profile' \
|| fail "acceptance: the production-profile projection is missing"
code_has apps/controller/config.go -F -- 'service.BuildHysteria2Profile()' \
|| fail "acceptance: the config route no longer returns the profile projection"
! code_has apps/controller/config.go -F -- 'service.GetHysteria2Config()' \
|| fail "acceptance: the config route serializes the internal model to the browser again"
# Списки секций профиля в Go и в оркестраторе обязаны совпадать: два
# источника истины разъехались бы молча, и панель начала бы называть
# расхождением то, что оркестратор считает нормой.
"$BUN_BIN" -e '
const fs = require("node:fs");
const go = fs.readFileSync("apps/service/hysteria2_profile.go", "utf8");
const ts = fs.readFileSync("orchestrator/src/steps/configAssertions.ts", "utf8");
const goBlock = go.match(/var hysteria2ProfileSections = \[\]string\{([\s\S]*?)\}/);
if (!goBlock) throw new Error("the Go profile section list is missing");
const goSections = [...goBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
const tsBlock = ts.match(/const allowed = new Set\(\[([\s\S]*?)\]\)/);
if (!tsBlock) throw new Error("the orchestrator profile section list is missing");
const tsSections = [...tsBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
// В оркестраторе режимы TLS взаимоисключающие, панель показывает оба.
const expected = [...new Set([...tsSections, "acme", "tls"])].sort();
if (goSections.join(",") !== expected.join(",")) {
throw new Error(
"profile sections diverged:\n go: " + goSections.join(",") + "\n ts: " + expected.join(",")
);
}
' || fail "acceptance: the profile section lists must not diverge"
log_step "Acceptance: the read-only config view carries no secrets"
# Скачиваемая выгрузка того же конфига секреты вырезает; читающий экран не
# имеет права быть щедрее. Пароль обфускации, токены ACME DNS и учётные
# данные outbound-прокси уезжали в браузер в открытом виде.
# Выравнивание полей структуры делает gofmt, поэтому число пробелов между
# именем и типом задавать нельзя.
code_has apps/model/vo/hysteria2_profile.go -E -- 'PasswordSet +bool' \
|| fail "acceptance: the profile view still carries the obfuscation password"
code_has apps/model/vo/hysteria2_profile.go -E -- 'SecretSet +bool' \
|| fail "acceptance: the profile view still carries the Traffic Stats secret"
! code_has apps/model/vo/hysteria2_profile.go -E -- '(Password|Secret) +\*?string' \
|| fail "acceptance: the profile view declares a secret-bearing field"
code_has apps/service/hysteria2_profile.go -F -- 'sanitizeURLValue(' \
|| fail "acceptance: the auth url reaches the browser with its machine token"
log_step "Acceptance: the config sanitizer follows YAML aliases"
# redactNode и redactSubtree разбирали документ, последовательность,
# отображение и скаляр, но не AliasNode: секрет за якорем покидал сервер
# дважды — и по ссылке, и в самом объявлении якоря.
code_has apps/service/hysteria2_export.go -F -- 'yaml.AliasNode' \
|| fail "acceptance: the config sanitizer ignores YAML aliases"
code_has apps/service/hysteria2_export.go -F -- 'func newAliasGuard' \
|| fail "acceptance: the alias walk has no cycle guard"
log_step "Acceptance: the removed lifecycle leftovers stay removed"
# Мёртвые остатки прежней архитектуры: лексикографическое сравнение версий
# без потребителя, пустая заглушка освобождения Hysteria и второй канал
# доставки QR-кода, который панель рисует сама.
[ ! -f apps/util/string.go ] \
|| fail "acceptance: the unused lexicographic CompareVersion is back"
local dead
for dead in CompareVersion ReleaseHysteria2; do
local carriers
carriers="$(code_mentions_in "${dead}(" \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
[ -z "$carriers" ] \
|| fail "acceptance: the removed ${dead} is referenced again: $carriers"
done
! code_has apps/model/vo/peer.go -F -- 'QrCode' \
|| fail "acceptance: the deprecated QR transport is back in the peer response"
}
# Наблюдаемость: журнал админки и страницы, которые его показывают.
run_observability_acceptance() {
log_step "Acceptance: request log entries carry a message"
# `entry.Info()` без аргумента писал `"msg":""` для КАЖДОГО запроса, и
# страница системных логов показывала пустой столбец сообщений.
code_has apps/middleware/log.go -F -- 'func RequestLogMessage' \
|| fail "acceptance: the request log message builder is missing"
! code_has apps/middleware/log.go -E -- 'entry\.(Info|Warn|Error)\(\)' \
|| fail "acceptance: request log entries are still written without a message"
log_step "Acceptance: the request log still hides query values"
# Человекочитаемое сообщение не является лазейкой: Hysteria обращается к
# машинному endpoint'у с действующим токеном в query при каждом подключении
# пира, а журнал уезжает оператору через ExportLog.
! code_has apps/middleware/log.go -F -- 'RequestURI' \
|| fail "acceptance: the request log must never carry the query string"
! code_has apps/middleware/log.go -F -- 'RawQuery' \
|| fail "acceptance: the request log must never carry query values"
log_step "Acceptance: the log page keeps its response shape on every branch"
# Ветка «файла ещё нет» отвечала голым массивом, панель читала
# `data.records` и получала undefined: на свежепоставленном хосте страница
# системных логов не работала вовсе.
! code_has apps/controller/log.go -F -- 'vo.Success(logSystemVos, c)' \
|| fail "acceptance: the system log page must always answer with {records,total}"
# Отказ внутри цикла писал ответ и продолжал перебор, а в конце безусловно
# выполнялся vo.Success: клиент получал два JSON-документа подряд.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/log.go", "utf8");
const start = source.indexOf("func LogSystem");
if (start < 0) throw new Error("LogSystem is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
const code = body
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (/vo\.Fail\([\s\S]*?\n\s*continue\b/.test(code)) {
throw new Error("a broken log line still writes a response and keeps looping");
}
' || fail "acceptance: a broken log line must be skipped, not answered"
log_step "Acceptance: both log pages share one table"
# Страницы были побайтово одинаковы и несли одни и те же дефекты в двух
# экземплярах: колонки без ширин и выгрузку с запросом вне try.
[ -f apps/frontend/src/components/LogViewer/index.vue ] \
|| fail "acceptance: the shared log table component is missing"
local page
for page in system hysteria; do
code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- 'LogViewer' \
|| fail "acceptance: the ${page} log page does not use the shared table"
! code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- '<el-table-column' \
|| fail "acceptance: the ${page} log page carries its own table again"
done
code_has apps/frontend/src/components/LogViewer/index.vue -E -- 'min-width="[0-9]+"' \
|| fail "acceptance: log table columns must declare explicit widths"
log_step "Acceptance: file download is built in exactly one place"
# Последовательность Blob/createObjectURL/ссылка/клик жила в четырёх копиях,
# и все успели разойтись: две из них ставили сетевой запрос ВНЕ try и
# глушили причину пустым catch.
local download_hits
download_hits="$(code_mentions_in 'createObjectURL' apps/frontend/src)"
[ "$download_hits" = "apps/frontend/src/utils/download.ts" ] \
|| fail "acceptance: file download must live in utils/download.ts alone. Found: $download_hits"
}
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
run_transaction_boundary_acceptance() {
log_step "Acceptance: disarming the firewall guard is proven, not announced"
# Снятие автоматического отката было утверждением, а не фактом:
#
# systemctl stop <unit>.timer <unit>.service || true
# -> "firewall rollback timer disarmed"
# -> phase=installed
#
# Отказ остановки стирался, и взведённый таймер мог вернуть прежний firewall
# уже после долговечной записи успеха.
grep -q 'export function buildAutoRollbackScript' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: скрипт автоотката снова собирается на месте вместо отдельной проверяемой функции"
grep -q 'auto-rollback-fired' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: маркер срабатывания guard'а отсутствует"
! grep -q 'stopRollbackTimer' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: вернулась stopRollbackTimer, снимавшая guard без доказательства"
grep -q 'export async function runMutatingStatus' orchestrator/src/lib/process.ts \
|| fail "acceptance: нет раннера, возвращающего код возврата вместо броска; снятие guard'а снова требует '|| true'"
# Скрипт автоотката проверяется как ТЕКСТ: скан заглушённых ошибок смотрит
# только на строки с runMutatingVisible и до содержимого скрипта не достаёт.
"$BUN_BIN" -e '
const { buildAutoRollbackScript } = await import("./orchestrator/src/steps/firewall.ts");
const script = buildAutoRollbackScript("2026-01-01T00-00-00.000Z");
const marker = script.indexOf("auto-rollback-fired");
const prepared = script.indexOf("$root/prepared");
if (marker < 0 || prepared < 0) throw new Error("в скрипте нет маркера срабатывания или проверки prepared");
if (marker > prepared) throw new Error("маркер срабатывания создаётся не первым действием");
for (const masked of ["|| true", "2>/dev/null", ">/dev/null 2>&1"]) {
if (script.includes(masked)) throw new Error("скрипт автоотката снова маскирует ошибки: " + masked);
}
if (!script.includes("exit \"$rc\"")) throw new Error("скрипт автоотката не возвращает накопленный код");
const failures = script.split("auto-rollback: failed").length - 1;
const raised = script.split("rc=1").length - 1;
if (failures === 0 || failures !== raised) {
throw new Error("не каждый сообщённый отказ поднимает код возврата: " + failures + " != " + raised);
}
// Создание маркера обязано входить в учёт rc. Инвариант фиксации
// "маркера нет и юниты inactive => guard не сработал" верен только при
// условии, что guard способен маркер записать. Пока rc=0 стояло ПОСЛЕ
// создания маркера, отказ записи не влиял ни на что: скрипт восстанавливал
// firewall, завершался нулём, и операция фиксировала успех после реально
// сработавшего отката.
const rcInit = script.indexOf("rc=0");
if (rcInit < 0 || rcInit > marker) throw new Error("rc объявляется после создания маркера");
if (script.includes("exit 0")) throw new Error("ранний выход теряет накопленный код возврата");
if (!script.includes("exit \"$rc\"")) throw new Error("ранний выход не возвращает rc");
// touch, а не `: >file`: двоеточие — special builtin POSIX, и ошибка
// перенаправления на нём обязана завершить неинтерактивный shell целиком.
// В dash, который на Debian и есть /bin/sh, скрипт умер бы ДО
// восстановления firewall.
if (script.includes(": >\"$root/auto-rollback-fired\"")) {
throw new Error("маркер создаётся перенаправлением на special builtin");
}
// ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса
// внутри guard стёрла бы только что восстановленные правила.
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
' || fail "acceptance: скрипт автоматического отката firewall нарушает свой контракт"
# Снятие guard'а обязано опираться на наблюдаемое состояние юнитов и на
# маркер, а не на код возврата systemctl: для уже отработавшего транзиентного
# юнита `systemctl stop` возвращает 5 — исход, неотличимый от успеха.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const start = source.indexOf("async function stopRollbackGuard");
const end = source.indexOf("export async function disarmFirewallRollback");
if (start < 0 || end < 0) throw new Error("не найдена функция снятия guard'"'"'а");
const body = source.slice(start, end);
const before = body.indexOf("before stopping the rollback guard");
const stop = body.indexOf("runMutatingStatus`systemctl stop");
const after = body.indexOf("after stopping the rollback guard");
if (before < 0 || stop < 0 || after < 0) throw new Error("маркер проверяется не с обеих сторон остановки");
if (!(before < stop && stop < after)) throw new Error("порядок обязан быть маркер -> stop -> маркер");
if (!body.includes("readUnitProperty(target, \"ActiveState\")")) {
throw new Error("снятие guard'"'"'а не подтверждается состоянием юнитов");
}
if (!source.includes("const GUARD_STOPPED_STATES_FOR_COMMIT = [\"inactive\"] as const")) {
throw new Error("на пути фиксации успеха допустимо не только inactive");
}
' || fail "acceptance: снятие guard'а обязано быть доказуемым"
log_step "Acceptance: a fired guard forbids the durable commit"
# Классификация по ТИПУ ошибки, а не по её тексту: разбор сообщения
# регулярным выражением уже убирали и из install, и из reconfigure.
grep -q 'export class FirewallGuardFiredError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: сработавший guard не имеет собственного типа ошибки"
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/install.ts \
|| fail "acceptance: install не отличает сработавший guard от прочих отказов"
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure не отличает сработавший guard от прочих отказов"
grep -q 'classifyFailure(ownership, phase, error)' orchestrator/src/commands/install.ts \
|| fail "acceptance: классификация отказа install не получает саму ошибку"
log_step "Acceptance: smoke verifies the effective firewall, not only its syntax"
# `nft -c` разбирает текущий файл, каким бы он ни был. Откатившийся прежний
# ruleset проходил эту проверку зелёным.
grep -q 'assertEffectiveFirewallIsOurs' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke не сверяет эффективный firewall с конфигурацией операции"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const syntax = source.indexOf("nft -c -f /etc/nftables.conf");
const semantic = source.indexOf("assertEffectiveFirewallIsOurs(context)");
if (syntax < 0 || semantic < 0) throw new Error("не найдены проверки firewall в smoke");
if (semantic < syntax) throw new Error("семантическая проверка идёт раньше разбора файла");
const firewall = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const start = firewall.indexOf("export async function assertEffectiveFirewallIsOurs");
const end = firewall.indexOf("function firewallRollbackIsInactive");
const body = firewall.slice(start, end);
for (const claim of ["await renderHy2xsNft(context)", "hy2xs-managed", "nft list table inet hy2xs"]) {
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
}
// Проверка выполняется и в doctor, то есть под read-only guard.
if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
' || fail "acceptance: проверка эффективного firewall нарушает свой контракт"
log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation"
grep -q 'async function cleanupFirewallCandidates' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: candidate-файлы firewall снова остаются на диске навсегда"
grep -q 'renderNftablesServiceState' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: состояние nftables.service не сохраняется и не восстанавливается откатом"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("export async function rollbackFirewallNow"));
const unitFile = body.indexOf("restore nftables.service unit file state");
const inactive = body.indexOf("restore nftables.service inactive state");
const ruleset = body.indexOf("apply restored ruleset");
if (unitFile < 0 || inactive < 0 || ruleset < 0) throw new Error("не найдены стадии восстановления сервиса");
// ExecStop у nftables.service делает `nft flush ruleset`, поэтому
// восстановление состояния сервиса обязано идти ДО применения ruleset.
if (!(unitFile < inactive && inactive < ruleset)) {
throw new Error("остановка сервиса идёт после применения ruleset и сотрёт его");
}
' || fail "acceptance: порядок восстановления firewall стирает восстановленные правила"
log_step "Acceptance: lifecycle operations are serialized by an exclusive lock"
# install-state.json замком не является: два одновременных reconfigure писали
# одни и те же production paths, а уникальные op-id разделяли только копии.
[ -f orchestrator/src/lib/operationLock.ts ] \
|| fail "acceptance: модуль взаимного исключения операций отсутствует"
grep -q '/run/lock/hy2xs-orchestrator.lock' orchestrator/src/lib/operationLock.ts \
|| fail "acceptance: замок операций не объявлен в /run/lock"
grep -q 'assertMutationAllowed' orchestrator/src/lib/operationLock.ts \
|| fail "acceptance: захват замка не спрашивает разрешения у read-only guard'а"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/cli.ts", "utf8");
for (const command of ["install", "reconfigure", "repair", "doctor"]) {
if (!source.includes("await withOperationLock(\"" + command + "\", () => " + command + "(options))")) {
throw new Error("команда выполняется без замка: " + command);
}
if (source.includes("await " + command + "(options)")) {
throw new Error("команда вызывается мимо замка: " + command);
}
}
// Отказ обязан произойти до первой проверки PHASE 0, а не после exec.
const assertAt = source.indexOf("await assertNoOperationInProgress(\"install preflight\")");
const preflightAt = source.indexOf("await preflightInstall(options)");
if (assertAt < 0 || preflightAt < 0) throw new Error("PHASE 0 не проверяет идущую операцию");
if (assertAt > preflightAt) throw new Error("PHASE 0 проверяет замок после собственных проверок");
// status и diagnostics нужны в том числе во время долгой операции.
for (const observer of ["status", "diagnostics"]) {
if (source.includes("withOperationLock(\"" + observer + "\"")) {
throw new Error("наблюдающая команда берёт эксклюзивный замок: " + observer);
}
}
if (!source.includes("await describeOperationInProgress()")) {
throw new Error("наблюдающие команды не сообщают об идущей операции");
}
' || fail "acceptance: политика взаимного исключения операций нарушена"
# Замок обязан сниматься при любом завершении: обрыв SSH (SIGHUP) не имеет
# права заблокировать сервер до перезагрузки.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
if (!source.includes(signal)) throw new Error("замок не снимается по сигналу " + signal);
}
if (!source.includes("process.on(\"exit\"")) throw new Error("замок не снимается при выходе процесса");
if (!source.includes("} finally {")) throw new Error("замок снимается не в finally");
// Снятие замка мёртвого держателя обязано идти через увод файла со сверкой
// nonce: unlink на месте способен снять уже живой замок.
if (!source.includes("async function reclaimStaleLock")) {
throw new Error("нет безопасного переиспользования замка мёртвого держателя");
}
if (!source.includes("stolen.nonce !== observed.nonce")) {
throw new Error("переиспользование замка не сверяет, что уводит именно мёртвый замок");
}
' || fail "acceptance: жизненный цикл замка операций нарушен"
log_step "Acceptance: a lifecycle operation waits for the previous one to become quiescent"
# Стык двух защитных механизмов. Замок защищает production paths, пока жив
# процесс-держатель; rollback guard firewall — отдельный systemd-объект,
# переживающий свой процесс. Аварийно умершая операция оставляла вооружённый
# guard, который возвращал прежний firewall уже посреди следующей операции.
# Уникальные op-id тут не помогают: каталоги копий разные, а
# /etc/nftables.conf и ruleset в ядре общие.
grep -q 'export async function assertNoPendingRollbackGuard' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
# «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и
# оператору по ним нужны разные действия: подождать окно отката либо чинить
# systemd. Один тип на оба означал бы, что различие существует только в тексте.
grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у недоказуемого состояния guard'а нет собственного типа"
"$BUN_BIN" -e '
const fs = require("node:fs");
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
if (!cli.includes("{ barrier: assertNoPendingRollbackGuard }")) {
throw new Error("захват замка идёт без барьера покоя");
}
// Единственное употребление withOperationLock — внутри
// runLifecycleOperation: иначе появился бы путь захвата мимо барьера.
const direct = cli.split("withOperationLock(").length - 1;
if (direct !== 1) throw new Error("замок берётся мимо runLifecycleOperation: " + direct + " употреблений");
if (!cli.includes("await assertNoPendingRollbackGuard()")) {
throw new Error("PHASE 0 не проверяет вооружённый guard предыдущей операции");
}
// Барьер обязан вызываться ДО захвата и ещё раз ПОСЛЕ: между этими
// моментами умирающая предыдущая операция успевает вооружить guard.
const lock = fs.readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
const acquire = lock.slice(lock.indexOf("export async function acquireOperationLock"));
const before = acquire.indexOf("await options.barrier?.()");
const write = acquire.indexOf("await writeLockFile(path, record)");
const after = acquire.indexOf("await options.barrier()");
if (before < 0 || write < 0 || after < 0) throw new Error("барьер проверяется не с обеих сторон захвата");
if (!(before < write && write < after)) throw new Error("порядок обязан быть барьер -> захват -> барьер");
if (!acquire.slice(after).includes("releaseSync(path, record.nonce)")) {
throw new Error("отказ второй проверки оставляет замок за собой");
}
// Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое
// состояние, которого автор не назвал, — включая те, которых он не знал:
// systemd 257 знает maintenance и refreshing помимо перечислявшихся.
const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) {
throw new Error("политика покоя guard изменилась без пересмотра барьера");
}
if (firewall.includes("GUARD_PENDING_STATES")) {
throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем");
}
// Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя.
// Прежний код возвращал пустой список, то есть принимал невозможность
// получить доказательство за положительный результат.
const inspectStart = firewall.indexOf("export async function inspectRollbackGuard");
const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard");
if (inspectStart < 0 || inspectEnd < inspectStart) {
throw new Error("нет единого наблюдателя состояния guard перед барьером");
}
const inspect = firewall.slice(inspectStart, inspectEnd);
if (inspect.includes("return [];")) {
throw new Error("барьер снова fail-open при отказе systemctl");
}
for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) {
if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome);
}
// Второй копии листинга быть не должно: status расходился с барьером по
// --plain, по `|| true` и по трактовке failed-юнита.
const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8");
if (status.includes("list-units")) {
throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя");
}
if (!status.includes("await inspectRollbackGuard()")) {
throw new Error("status не берёт состояние guard у общего наблюдателя");
}
' || fail "acceptance: барьер покоя между операциями нарушен"
log_step "Acceptance: the transient guard timer states its window and its cleanup"
# `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает
# себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard,
# про который README и docs говорят «45 секунд», без явного значения имел
# контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от
# выгрузки отработавшего таймера зависит право барьера считать его отсутствие
# покоем, и держать этот инвариант на чужом умолчании нельзя.
"$BUN_BIN" -e '
const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts");
const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z");
for (const expected of [
"--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service",
"--on-active=45s",
"--timer-property=RemainAfterElapse=no",
"--timer-property=AccuracySec=1s"
]) {
if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected);
}
// Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой
// команде не участвует, поэтому единственная защита — отказ.
let rejected = false;
try { buildArmGuardArgv("op id"); } catch { rejected = true; }
if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard");
' || fail "acceptance: контракт транзиентного таймера guard нарушен"
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
# enabled-runtime таким вызовом обещало точность, которой не давало.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("restore nftables.service unit file state"));
const end = body.indexOf("restore nftables.service inactive state");
const stage = body.slice(0, end);
for (const guessed of ["enable --runtime", "systemctl mask"]) {
if (stage.includes(guessed)) {
throw new Error("восстановление UnitFileState снова обещает недостижимую точность: " + guessed);
}
}
if (!stage.includes("case \"enabled\":") || !stage.includes("case \"disabled\":")) {
throw new Error("не восстанавливаются состояния, которые операция реально меняет");
}
if (!stage.includes("is left as is")) {
throw new Error("невосстановимое состояние не называется оператору явно");
}
' || fail "acceptance: восстановление состояния nftables.service нарушает свой контракт"
log_step "Acceptance: the operation key has a single source"
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
# назывался санитизированным ключом: путь из runbook не существовал.
grep -q 'export function operationKeyFor' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: ключ операции не объявлен единственной функцией"
grep -q 'opId: operationKeyFor(context.installDate)' orchestrator/src/commands/install.ts \
|| fail "acceptance: install снова пишет в маркер собственный вариант ключа операции"
# Формула живёт ровно в одном файле — там, где объявлена operationKeyFor.
local key_formula
key_formula="$(grep -rl 'installDate.replace(' orchestrator/src \
| grep -v 'orchestrator/src/steps/firewall.ts' || true)"
[ -z "$key_formula" ] \
|| fail "acceptance: формула ключа операции продублирована в: $key_formula"
}
# Compatibility-слой предыдущего поколения не должен пережить 1.0.
run_legacy_account_acceptance() {
log_step "Acceptance: the legacy account layer is gone from the runtime"
[ ! -f apps/model/entity/account.go ] \
|| fail "acceptance: the LegacyAccount entity came back"
# Каталоги перечислены явно: рекурсия по apps захватила бы
# apps/frontend/node_modules, который к этому шагу сборки уже существует.
! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \
|| fail "acceptance: legacy account migration code came back"
# Ищется запись в списке миграций (имя в кавычках), а не упоминание:
# комментарий, объясняющий, почему миграции удалены, должен быть разрешён.
local dead_migration
for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do
! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \
|| fail "acceptance: legacy migration ${dead_migration} came back"
done
# Номера оставшихся миграций не перенумеровываются: они уже записаны в
# schema_migrations на установленных машинах.
local kept_migration
for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do
grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \
|| fail "acceptance: migration ${kept_migration} disappeared or was renumbered"
done
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
# Руководство по очистке предыдущего поколения — единственное место, где эти
# имена обозначают реальные объекты для удаления. В остальных docs их быть не
# должно.
#
# Обход РЕКУРСИВНЫЙ. Раньше здесь стоял плоский `docs/*.md`, и это работало,
# пока документы лежали одной кучей в корне docs. После разнесения по
# тематическим каталогам такой шаблон совпадал бы ровно с одним файлом —
# docs/README.md, — то есть проверка отчитывалась бы зелёным, не заглянув
# почти никуда.
local doc
while IFS= read -r doc; do
[ -n "$doc" ] || continue
case "$doc" in
docs/operations/14-legacy-cleanup.md) continue ;;
esac
! grep -q 'H_UI_' "$doc" \
|| fail "acceptance: previous-generation config keys leaked into $doc"
done <<EOF
$(find docs -type f -name '*.md' | sort)
EOF
# Каталог обязан быть непустым: `find` по опечатке в пути вернул бы пустой
# список, а пустой список для проверки «такого здесь нет» означает успех.
[ "$(find docs -type f -name '*.md' | wc -l)" -ge 10 ] \
|| fail "acceptance: обход документации нашёл подозрительно мало файлов"
}