3076 lines
220 KiB
Bash
3076 lines
220 KiB
Bash
#!/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"
|
||
# runtime env mappings live in runtimeEnvEntries(); renderRuntimeEnv only
|
||
# serializes that shared list. Do not require the old inline interpolation.
|
||
! grep -Eq \
|
||
'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' \
|
||
orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: runtime env must not contain a hardcoded obfs type"
|
||
grep -Eq \
|
||
'\["HY2XS_HYSTERIA_OBFS_TYPE",[[:space:]]*config\.hysteriaObfsType\]' \
|
||
orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: runtimeEnvEntries must derive the obfs type from the parsed config"
|
||
grep -Eq \
|
||
'renderEnvFile\(runtimeEnvEntries\(config\)\)' \
|
||
orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: renderRuntimeEnv must render the shared runtimeEnvEntries list"
|
||
! grep -Eq \
|
||
'\|\|[[:space:]]*"(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(runtimeEnvEntries(config))' \
|
||
|| 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"
|
||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][eE][fF][fF]' \
|
||
|| fail "acceptance: $unicode_domain_owner не отвергает документированный U+FEFF"
|
||
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
|
||
|
||
log_step "Acceptance: text files use fatal UTF-8 decoding"
|
||
code_has orchestrator/src/lib/fs.ts -F -- '.bytes()' \
|
||
|| fail "acceptance: readText снова не читает исходные байты"
|
||
code_has orchestrator/src/lib/fs.ts -F -- 'fatal: true' \
|
||
|| fail "acceptance: readText снова допускает replacement decoding"
|
||
code_has orchestrator/src/lib/fs.ts -F -- 'ignoreBOM: true' \
|
||
|| fail "acceptance: reader снова съедает BOM до проверки U+FEFF"
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
|
||
const start = source.indexOf("export async function readText");
|
||
const end = source.indexOf("\nexport async function writeText", start);
|
||
const body = source.slice(start, end);
|
||
if (body.includes(".text()")) throw new Error("readText использует replacement decoding");
|
||
' || fail "acceptance: строгий файловый reader заменён на Blob.text()"
|
||
! grep -R -F --include='*.ts' 'Bun.file(' orchestrator/tools \
|
||
| grep -F '.text()' >/dev/null \
|
||
|| fail "acceptance: build-time tool обходит строгий файловый reader"
|
||
|
||
# Порт автомата обязан повторять 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"));
|
||
const rawValidation = body.indexOf("assertEnvTransportable");
|
||
const parsing = body.indexOf("parseEnv(content)");
|
||
if (rawValidation < 0 || parsing < 0 || rawValidation > parsing) {
|
||
throw new Error("исходный EnvironmentFile не проверяется до разбора");
|
||
}
|
||
if (!body.includes("validateRuntimeEnvTransport(config)")) {
|
||
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
|
||
}
|
||
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации"
|
||
|
||
log_step "Acceptance: JSON is strict before encoding/json"
|
||
code_has apps/controller/json_body.go -F -- 'utf8.Valid(raw)' \
|
||
|| fail "acceptance: HTTP JSON снова допускает повреждённый UTF-8"
|
||
code_has apps/controller/json_body.go -F -- 'validateStrictJSONEncoding(raw)' \
|
||
|| fail "acceptance: strict JSON binder не проверяет Unicode escapes"
|
||
code_has apps/controller/validator.go -F -- 'strictBindJSON(c, &field)' \
|
||
|| fail "acceptance: DTO снова разбираются напрямую через Gin/encoding/json"
|
||
code_has apps/controller/hysteria2.go -F -- 'strictBindJSON(c, &req)' \
|
||
|| fail "acceptance: Hysteria auth обходит строгую JSON-границу"
|
||
code_has apps/controller/peer.go -F -- 'validateStrictJSONEncoding(content)' \
|
||
|| fail "acceptance: импорт пиров обходит строгую JSON-границу"
|
||
|
||
log_step "Acceptance: diagnostics never stages raw secrets"
|
||
! code_has orchestrator/src/commands/diagnostics.ts -E -- 'cp -a .*(hy2xs\.env|post-install\.env|config\.yaml)' \
|
||
|| fail "acceptance: diagnostics снова копирует секретный конфиг в staging до редакции"
|
||
code_has orchestrator/src/commands/diagnostics.ts -F -- 'prepareDiagnosticFile(source, sanitize)' \
|
||
|| fail "acceptance: diagnostics не проводит файл через строгую подготовку до записи"
|
||
! code_has orchestrator/src/commands/diagnostics.ts -F -- 'Bun.write' \
|
||
|| fail "acceptance: diagnostics снова пишет сырой промежуточный файл напрямую"
|
||
|
||
log_step "Acceptance: env files are read as data, never executed"
|
||
# Файл научился нести произвольные значения, и это сделало `source` опасным:
|
||
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
|
||
# (подстановок в EnvironmentFile нет), а bash внутри двойных кавычек выполнил
|
||
# бы подстановку команд - от root, прямо в рецепте восстановления доступа.
|
||
#
|
||
# Проверяются КОМАНДЫ: объяснение, почему такой строки здесь больше нет,
|
||
# обязано называть её по имени, поэтому текст вне ```-блоков не считается.
|
||
local env_doc fenced_code
|
||
while IFS= read -r env_doc; do
|
||
[ -n "$env_doc" ] || continue
|
||
# fenced_code_of сначала обязан дочитать документ целиком. `grep -q` в
|
||
# pipeline под pipefail закрывал канал после первого совпадения и мог
|
||
# превратить найденную запрещённую команду в ложный PASS через SIGPIPE 141.
|
||
fenced_code="$(fenced_code_of "$env_doc")" \
|
||
|| fail "acceptance: не удалось прочитать fenced code из $env_doc"
|
||
if grep -qE \
|
||
'(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|hy2xs\.env|bootstrap-admin\.secret)' \
|
||
<<<"$fenced_code"; then
|
||
fail "acceptance: $env_doc снова предлагает исполнить env-файл шеллом (source/.)"
|
||
fi
|
||
if grep -qE \
|
||
'eval[[:space:]]+[^|]*(hy2xs\.env|bootstrap-admin\.secret)' \
|
||
<<<"$fenced_code"; 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: diagnostics writes only into a trusted root namespace"
|
||
grep -qF 'export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`' \
|
||
orchestrator/src/lib/diagnosticsStorage.ts \
|
||
|| fail "acceptance: diagnostics root must live below /var/lib/hy2xs"
|
||
! grep -qF '/var/log/hy2xs/diagnostics' orchestrator/src/commands/diagnostics.ts \
|
||
|| fail "acceptance: root diagnostics must not use the service-writable log namespace"
|
||
grep -q 'ensureDiagnosticsStorageRoot' orchestrator/src/steps/filesystem.ts \
|
||
|| fail "acceptance: install must prepare the root-only diagnostics directory"
|
||
"$BUN_BIN" -e '
|
||
const fs = require("node:fs");
|
||
const storage = fs.readFileSync("orchestrator/src/lib/diagnosticsStorage.ts", "utf8");
|
||
const diagnostics = fs.readFileSync("orchestrator/src/commands/diagnostics.ts", "utf8");
|
||
for (const marker of ["lstat(", "isSymbolicLink()", "mkdtemp(", "open(archive, \"wx\"", "exactMode: DIAGNOSTICS_MODE"]) {
|
||
if (!storage.includes(marker)) throw new Error("missing diagnostics trust-boundary marker: " + marker);
|
||
}
|
||
if (!diagnostics.includes("createDiagnosticsWorkspace()")) {
|
||
throw new Error("diagnostics does not enter the trusted storage boundary");
|
||
}
|
||
if (!diagnostics.includes("runMutatingArgv([\"tar\"")) {
|
||
throw new Error("diagnostics archive creation must not use a shell string");
|
||
}
|
||
' || fail "acceptance: diagnostics storage is not fail-closed against symlink substitution"
|
||
|
||
log_step "Acceptance: metadata fallback is ENOENT-only"
|
||
"$BUN_BIN" -e '
|
||
const fs = require("node:fs");
|
||
const source = fs.readFileSync("orchestrator/src/lib/packageMeta.ts", "utf8");
|
||
if (!source.includes(".code === \"ENOENT\"")) throw new Error("missing explicit ENOENT branch");
|
||
if (!source.includes("throw error")) throw new Error("non-ENOENT metadata errors are not rethrown");
|
||
const tests = fs.readFileSync("orchestrator/test/package-meta-utf8.test.ts", "utf8");
|
||
if (!tests.includes("каталог вместо metadata-файла")) throw new Error("missing non-ENOENT regression test");
|
||
' || fail "acceptance: package metadata errors may still be masked by fallback"
|
||
|
||
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: обход документации нашёл подозрительно мало файлов"
|
||
}
|