Files
HY2XS_flamy/tools/build/lib/acceptance.sh
T
founder e84fdedc4b fix(v1): сделать откат неотменяемым, а маркер установки — долговечным
Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
2026-08-30 18:11:55 +05:00

1097 lines
74 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, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
# третьего раза не было.
#
# Вид комментария выбирается по расширению файла.
#
# Раньше отбрасывались только строки, начинающиеся с `//`, поэтому сканы по
# tools/build/*.sh не работали вовсе: там комментарии начинаются с решётки, и
# объяснение «почему этого больше нет» считалось за код.
#
# Решётка при этом отбрасывается ТОЛЬКО в shell. В шаблоне Vue строка вполне
# может начинаться с `#default="scope"` — это сокращение для v-slot, а не
# комментарий, и общий фильтр молча выбрасывал бы её из скана.
code_without_comments() {
case "$1" in
*.sh) grep -vE '^[[:space:]]*#' "$1" 2>/dev/null || true ;;
*) grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true ;;
esac
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
shift
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_without_comments "$file" | grep -qF -- "$needle"; then
hits="${hits}${hits:+ }${file}"
fi
done <<EOF
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
EOF
printf '%s' "$hits"
}
run_fix20_acceptance_subset() {
local package_dir="$1"
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
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 registry matches current upstream"
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
local provider
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
done
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/11-testing-and-acceptance.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 -rq 'virtual:svg-icons-register' apps/frontend/src \
|| fail "acceptance: виртуальный модуль удалённого плагина иконок вернулся"
grep -q 'registerSvgIcons' apps/frontend/src/main.ts \
|| fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения"
# Каждая иконка обязана давать symbol с viewBox.
#
# Без viewBox `<use>` не знает систему координат и рисует иконку в натуральную
# величину, обрезая её по размеру родительского svg. Три иконки из
# семнадцати его не объявляют — для них viewBox синтезируется из width/height,
# как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка,
# добавленная без обоих способов задать координаты, иначе сломала бы
# отрисовку молча.
"$BUN_BIN" -e '
const fs = require("node:fs");
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) {
const raw = fs.readFileSync(`${dir}/${file}`, "utf8");
const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").match(/<svg\b[^>]*>/i);
if (!openTag) { broken.push(`${file}: нет корневого <svg>`); continue; }
const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]);
const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]);
if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`);
}
if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не даёт корректный <symbol> для спрайта"
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"
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back"
local unguarded_runner
unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$unguarded_runner" -ge 4 ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
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/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_single_owner_acceptance "$package_dir"
run_secret_channel_acceptance
run_atomic_import_acceptance
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
[ -f apps/service/cron_scheduler.go ] \
|| fail "acceptance: модуль планировщика отсутствует"
[ ! -f apps/middleware/cron.go ] \
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
# Именно этот путь плодил планировщики — StopServer возвращал управление в
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
# внутрипроцессного перезапуска.
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
const release = source.indexOf("defer releaseResource()");
const stopCron = source.indexOf("defer service.StopCron()");
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
// defer выполняются в обратном порядке регистрации: releaseResource должен
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
grep -q 'ValidateConfigValue' apps/controller/config.go \
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
grep -q 'func WithConfigTx' apps/dao/config.go \
|| fail "acceptance: у записи настроек нет транзакционной границы"
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
# Проверка обязана завершиться ДО первой записи.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
const validate = source.indexOf("service.ValidateConfigValue(");
const apply = source.indexOf("service.UpdateConfigs(updates)");
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
}
# Мёртвые ключи таблицы `config` не должны вернуться.
run_dead_config_acceptance() {
log_step "Acceptance: dead config keys stay removed"
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
# другое обязано называть их по имени.
local dead_key hits
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
hits="$(code_mentions_in "$dead_key" \
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
[ -z "$hits" ] \
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
done
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
# была вторым источником истины и читалась ПЕРВОЙ.
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
log_step "Acceptance: share URI remark is derived, not stored as dead state"
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
}
# Гигиена зависимостей и bootstrap-учётных данных.
run_dependency_hygiene_acceptance() {
local package_dir="$1"
log_step "Acceptance: bootstrap credentials are never invented or logged"
# Ключевой канал утечки: сгенерированный пароль печатался в
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
grep -q 'func ExportAdminLog' apps/service/journal.go \
|| fail "acceptance: журнал админки выгружается без санитайза"
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
local jwt_v3_hits
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
[ -z "$jwt_v3_hits" ] \
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
log_step "Acceptance: password storage has exactly one format"
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
log_step "Acceptance: the release is built with a dependency vulnerability gate"
[ -f tools/build/lib/security.sh ] \
|| fail "acceptance: модуль проверки зависимостей отсутствует"
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|| fail "acceptance: сборка не запускает проверку зависимостей"
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
# По готовому tarball должно быть видно, что он проверялся — ровно как
# hysteria_compat_gate.
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
# У шага не должно быть обходов — ни объявленных, ни забытых.
#
# Раньше их было два: 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
# Внутри восстановления конфигов дефект был тот же: единственная команда без
# `|| true` отменяла перезапуск сервисов строкой ниже.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const start = source.indexOf("async function rollbackCurrentState");
if (start < 0) throw new Error("rollbackCurrentState отсутствует");
const rest = source.slice(start + 1);
const end = rest.search(/\n(export )?(async )?function /);
const body = end < 0 ? rest : rest.slice(0, end);
const offenders = body.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
if (offenders.length) {
throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n"));
}
' || fail "acceptance: ни одна команда отката не имеет права отменить остальные"
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, not by message text"
! 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)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure failure classification must be ownership-driven"
}
# Каналы утечки секретов: 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)"
printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \
|| fail "acceptance: the request logger must log the path, not RequestURI"
! printf '%s\n' "$log_code" | grep -q 'RequestURI' \
|| 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"
! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
printf '%s\n' "$server_code" | grep -q 'gin.New()' \
|| fail "acceptance: the admin engine must be built with gin.New()"
printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \
|| 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
}
# 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/14 — единственное место, где эти имена обозначают реальные объекты
# для удаления. В обычных docs их быть не должно.
local doc
for doc in docs/*.md; do
case "$doc" in
docs/14-legacy-cleanup.md) continue ;;
esac
! grep -q 'H_UI_' "$doc" \
|| fail "acceptance: previous-generation config keys leaked into $doc"
done
}