cf094f6e6f
Проход по операциям, которые делают не то, что обещает их имя. P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config: «создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной транзакцией. P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь недоступный секрет любого пира отклоняет весь запрос с указанием имени. P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет, если оно не начиналось с v1: — остаток поколения с открытыми секретами. P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на проблему обрывала все живые соединения. P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в /etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine auth переставал совпадать. P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и документации, отсутствие проверяется приёмкой. P2. UPDATE по отсутствующей строке config считался успехом, и cron перепланировался при несохранённом значении. Решение по RowsAffected. P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage. P2. Удалены алиасы /:id/client-url и /:id/qr. Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go — языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют среду с versions.env.
872 lines
58 KiB
Bash
872 lines
58 KiB
Bash
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# code_without_comments отдаёт содержимое файла без строк-комментариев.
|
||
#
|
||
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
|
||
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
|
||
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
|
||
# документации, которая описывает выполненную ею же работу.
|
||
#
|
||
# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на
|
||
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
|
||
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
|
||
# третьего раза не было.
|
||
#
|
||
# Отбрасываются оба вида строк-комментариев: `//` для Go и TypeScript, `#` для
|
||
# shell. Раньше стриплось только `//`, поэтому сканы по tools/build/*.sh не
|
||
# работали вовсе — там комментарии начинаются с решётки, и объяснение «почему
|
||
# этого больше нет» считалось за код. Ни один из языков не использует префикс
|
||
# другого для чего-то иного, так что общий фильтр безопасен для обоих.
|
||
code_without_comments() {
|
||
grep -vE '^[[:space:]]*(//|#)' "$1" 2>/dev/null || true
|
||
}
|
||
|
||
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
|
||
code_mentions_in() {
|
||
local needle="$1"
|
||
shift
|
||
|
||
local file hits=""
|
||
while IFS= read -r file; do
|
||
[ -n "$file" ] || continue
|
||
if code_without_comments "$file" | grep -qF -- "$needle"; then
|
||
hits="${hits}${hits:+ }${file}"
|
||
fi
|
||
done <<EOF
|
||
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
|
||
EOF
|
||
|
||
printf '%s' "$hits"
|
||
}
|
||
|
||
run_fix20_acceptance_subset() {
|
||
local package_dir="$1"
|
||
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
|
||
|
||
log_step "Acceptance: package layout sanity"
|
||
[ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable"
|
||
[ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing"
|
||
|
||
log_step "Acceptance: project license is shipped with the package"
|
||
[ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package"
|
||
grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \
|
||
|| fail "acceptance: packaged LICENSE must be AGPL-3.0-only"
|
||
grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: package metadata must declare license=AGPL-3.0-only"
|
||
|
||
log_step "Acceptance: orchestrator CLI help path"
|
||
"$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true
|
||
|
||
log_step "Acceptance: firewall mode defaults in config"
|
||
grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config"
|
||
|
||
log_step "Acceptance: baseline domain/public host/ssh defaults"
|
||
grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro"
|
||
grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro"
|
||
grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323"
|
||
|
||
log_step "Acceptance: force password change production default"
|
||
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
|
||
|
||
log_step "Acceptance: config schema version is declared"
|
||
# Значение берётся из versions.env, а не пишется числом: захардкоженная
|
||
# двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про
|
||
# эту строку. Источник истины у схемы ровно один.
|
||
grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \
|
||
|| fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}"
|
||
|
||
log_step "Acceptance: public endpoint policy is declared and strict by default"
|
||
grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \
|
||
|| fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict"
|
||
|
||
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
|
||
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|
||
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
|
||
|
||
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
|
||
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|
||
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
|
||
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|
||
|| fail "acceptance: hysteria template must not hardcode an obfs type"
|
||
|
||
log_step "Acceptance: modern server baseline is present in the template"
|
||
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|
||
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
|
||
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|
||
|| fail "acceptance: congestion block missing from hysteria template"
|
||
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|
||
|| fail "acceptance: quic block missing from hysteria template"
|
||
|
||
log_step "Acceptance: post-install env derives obfs type from resolved context"
|
||
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|
||
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
|
||
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|
||
|| fail "acceptance: post-install env must not hardcode an obfs type"
|
||
|
||
log_step "Acceptance: production defaults are declared in exactly one module"
|
||
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|
||
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
|
||
local default_declarations
|
||
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
|
||
| grep -v 'orchestrator/src/config/profile.ts' || true)"
|
||
[ -z "$default_declarations" ] \
|
||
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
|
||
|
||
log_step "Acceptance: runtime env is derived from config, not literals"
|
||
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
|
||
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
|
||
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
|
||
|
||
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
|
||
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: metadata must record hysteria_resolution"
|
||
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: metadata must record hysteria_resolved_at"
|
||
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
|
||
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
|
||
|
||
log_step "Acceptance: install-time never resolves a moving latest"
|
||
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|
||
|| fail "acceptance: install-time code must not query the upstream release API"
|
||
! grep -rq 'download.hysteria.network' orchestrator/src \
|
||
|| fail "acceptance: install-time code must not use the moving latest download URL"
|
||
|
||
log_step "Acceptance: smoke verifies config semantics, not substrings"
|
||
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|
||
|| fail "acceptance: smoke must verify the effective config semantically"
|
||
|
||
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
|
||
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|
||
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
|
||
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|
||
|| fail "acceptance: export must read the raw YAML instead of the typed model"
|
||
|
||
log_step "Acceptance: frontend ACME registry matches current upstream"
|
||
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
|
||
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|
||
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
|
||
local provider
|
||
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
|
||
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|
||
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
|
||
done
|
||
|
||
log_step "Acceptance: systemd unit production env"
|
||
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
|
||
|
||
log_step "Acceptance: docs matrix markers"
|
||
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing"
|
||
|
||
log_step "Acceptance: machine auth URL in templates"
|
||
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
|
||
grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL"
|
||
|
||
log_step "Acceptance: smoke auth checks are tokenized"
|
||
grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token"
|
||
# Проверяется контракт, а не литерал URL.
|
||
#
|
||
# Регрессия приёмки: здесь стоял grep по строке
|
||
# `hysteria2/auth?access_token=${...}`. После переезда machine-auth на
|
||
# /internal/hysteria/auth и централизации пути в профиле такой строки в
|
||
# smoke.ts не существует — приёмка падала на корректном коде, причём в самом
|
||
# конце сборки, внутри verify_archive. Единственный источник истины у пути
|
||
# один, поэтому и проверять нужно обращение к нему.
|
||
grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \
|
||
|| fail "acceptance: smoke must take the machine-auth path from the production profile"
|
||
grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \
|
||
|| fail "acceptance: smoke auth URL must be built by the production helper (tokenized)"
|
||
! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \
|
||
|| fail "acceptance: smoke must not assemble the machine token into a URL by hand"
|
||
|
||
log_step "Acceptance: bootstrap peer can pass auth smoke"
|
||
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
|
||
|
||
log_step "Acceptance: frontend 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 written install-state already makes the failure post-apply"
|
||
# Регрессия: classifyFailure не учитывал stateWritten, поэтому падение
|
||
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
|
||
# а install-state.json оставался на хосте и ломал следующую установку.
|
||
grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|
||
|| fail "acceptance: classifyFailure must account for a written install-state"
|
||
"$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 stateWritten = body.indexOf("ownership.stateWritten");
|
||
if (preApply < 0 || stateWritten < 0) {
|
||
throw new Error("could not locate classifyFailure branches");
|
||
}
|
||
if (stateWritten > preApply) {
|
||
throw new Error("stateWritten is checked after the fatal_pre_apply fallback");
|
||
}
|
||
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written"
|
||
|
||
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
|
||
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
|
||
# систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await.
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
||
const steps = [
|
||
["depsTouched", "await installDeps("],
|
||
["filesystemTouched", "await prepareFilesystem("],
|
||
["uiTouched", "await deployUi("],
|
||
["hysteriaTouched", "await installHysteria("],
|
||
["configTouched", "await generateConfig("],
|
||
["unitsTouched", "await deploySystemd("],
|
||
["firewallTouched", "await applyFirewall("],
|
||
["postInstallTouched", "await writePostInstallEnv("],
|
||
["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("]
|
||
];
|
||
for (const [flag, call] of steps) {
|
||
const flagAt = source.indexOf("ownership." + flag + " = true");
|
||
const callAt = source.indexOf(call);
|
||
if (flagAt < 0) throw new Error("missing ownership flag: " + flag);
|
||
if (callAt < 0) throw new Error("missing step call: " + call);
|
||
if (flagAt > callAt) throw new Error(flag + " is raised after " + call);
|
||
}
|
||
' || fail "acceptance: ownership flags must be raised before the mutating step they cover"
|
||
|
||
log_step "Acceptance: the read-only phase has no universal runner to slip through"
|
||
# Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`,
|
||
# guard держался на внимательности автора правки.
|
||
grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \
|
||
|| fail "acceptance: process.ts must expose an explicit read-only runner"
|
||
grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \
|
||
|| fail "acceptance: process.ts must expose an explicit mutating runner"
|
||
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|
||
|| fail "acceptance: the pre-split runner names must not come back"
|
||
local unguarded_runner
|
||
unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
|
||
[ "$unguarded_runner" -ge 4 ] \
|
||
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
|
||
|
||
log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
|
||
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|
||
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server"
|
||
[ -f orchestrator/src/steps/networkEndpoint.ts ] \
|
||
|| fail "acceptance: the public endpoint invariant module is missing"
|
||
! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \
|
||
|| fail "acceptance: the server address must be resolved locally, not via an external service"
|
||
grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \
|
||
|| fail "acceptance: local public IPv4 set must come from the host interfaces"
|
||
|
||
log_step "Acceptance: purge and clean-host describe the same boundary"
|
||
local purged_path
|
||
for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do
|
||
grep -qF "$purged_path" tools/legacy/purge-v0.sh \
|
||
|| fail "acceptance: purge-v0.sh no longer removes $purged_path"
|
||
done
|
||
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|
||
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
|
||
# Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его
|
||
# создаёт steps/bootstrap.ts, и две копии строки разошлись бы.
|
||
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|
||
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
|
||
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|
||
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
|
||
|
||
log_step "Acceptance: admin secrets never reach a persistent export file"
|
||
# Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export
|
||
# оставлял на диске JSON с расшифрованными секретами пиров.
|
||
[ ! -f apps/util/export.go ] \
|
||
|| fail "acceptance: the file-based export helper came back"
|
||
! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \
|
||
|| fail "acceptance: the persistent export directory came back"
|
||
grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \
|
||
|| fail "acceptance: peer export must be served from memory"
|
||
|
||
log_step "Acceptance: peer import is validated as strictly as peer creation"
|
||
grep -q 'ValidatePeerImportBatch' apps/service/peer.go \
|
||
|| fail "acceptance: peer import must validate the whole batch before writing"
|
||
grep -q 'DisallowUnknownFields' apps/controller/peer.go \
|
||
|| fail "acceptance: peer import must reject unknown fields instead of silently defaulting"
|
||
grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \
|
||
|| fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer"
|
||
|
||
log_step "Acceptance: missing config schema marker is treated as legacy"
|
||
grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \
|
||
|| fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted"
|
||
|
||
log_step "Acceptance: reconfigure/repair verify the installation generation"
|
||
grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \
|
||
|| fail "acceptance: reconfigure/repair must verify release line and config schema in install-state"
|
||
grep -q 'release_line' orchestrator/src/lib/installState.ts \
|
||
|| fail "acceptance: install-state must carry the product release line"
|
||
|
||
log_step "Acceptance: diagnostics redaction is structural"
|
||
grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \
|
||
|| fail "acceptance: YAML redaction must walk the document, not match lines"
|
||
# Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в
|
||
# заголовок mapping'а и оставляло вложенный auth.http.url с machine token.
|
||
! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \
|
||
|| fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url"
|
||
|
||
log_step "Acceptance: dead updater/config-write routes stay removed"
|
||
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
|
||
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
|
||
#
|
||
# Тесты исключены по той же причине, и это не послабление: router_test.go
|
||
# ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице
|
||
# маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном
|
||
# регрессионном тесте — то есть добавление теста, закрепляющего удаление
|
||
# маршрута, ломало сборку.
|
||
local dead_route
|
||
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do
|
||
! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \
|
||
|| fail "acceptance: removed route ${dead_route} came back"
|
||
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|
||
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
|
||
done
|
||
|
||
log_step "Acceptance: e2e uses the production share URI generator"
|
||
grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \
|
||
|| fail "acceptance: e2e must build the share URI with production code"
|
||
! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \
|
||
|| fail "acceptance: the bash share-URI implementation must not come back"
|
||
|
||
log_step "Acceptance: upstream checksums are verified before the lock is written"
|
||
grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \
|
||
|| fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt"
|
||
|
||
log_step "Acceptance: toolchain checksums live in versions.env"
|
||
grep -q '^BUN_LINUX_X64_SHA256=' versions.env \
|
||
|| fail "acceptance: versions.env must pin the Bun x64 artifact"
|
||
grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \
|
||
|| fail "acceptance: versions.env must pin the Bun baseline artifact"
|
||
grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \
|
||
|| fail "acceptance: versions.env must pin the Go toolchain archive"
|
||
grep -q '^NODE_LINUX_X64_SHA256=' versions.env \
|
||
|| fail "acceptance: versions.env must pin the Node.js archive"
|
||
! grep -q '^HYSTERIA_VERSION=' versions.env \
|
||
|| fail "acceptance: versions.env declares the Hysteria policy, not a pinned version"
|
||
|
||
log_step "Acceptance: admin version comes from the build contract"
|
||
grep -q 'var Version = "dev"' apps/model/constant/system.go \
|
||
|| fail "acceptance: admin version must be injected via ldflags, not hardcoded"
|
||
grep -q 'constant.Version=v' tools/build/lib/package.sh \
|
||
|| fail "acceptance: the build must inject the admin version from versions.env"
|
||
|
||
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
|
||
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
|
||
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
|
||
! grep -q 'purge-v0' "$package_dir/install.sh" \
|
||
|| fail "acceptance: the installer must never run destructive cleanup on its own"
|
||
|
||
run_single_owner_acceptance "$package_dir"
|
||
run_secret_channel_acceptance
|
||
run_atomic_import_acceptance
|
||
run_legacy_account_acceptance
|
||
run_scheduler_lifecycle_acceptance
|
||
run_dead_config_acceptance
|
||
run_dependency_hygiene_acceptance "$package_dir"
|
||
}
|
||
|
||
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
||
run_scheduler_lifecycle_acceptance() {
|
||
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
||
|
||
[ -f apps/service/cron_scheduler.go ] \
|
||
|| fail "acceptance: модуль планировщика отсутствует"
|
||
[ ! -f apps/middleware/cron.go ] \
|
||
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
|
||
|
||
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
|
||
# Именно этот путь плодил планировщики — StopServer возвращал управление в
|
||
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
|
||
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
|
||
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
|
||
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|
||
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
|
||
|
||
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
|
||
# внутрипроцессного перезапуска.
|
||
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|
||
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
|
||
|
||
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
|
||
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|
||
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
|
||
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|
||
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
|
||
const release = source.indexOf("defer releaseResource()");
|
||
const stopCron = source.indexOf("defer service.StopCron()");
|
||
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
|
||
// defer выполняются в обратном порядке регистрации: releaseResource должен
|
||
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
|
||
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
|
||
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
|
||
|
||
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
|
||
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|
||
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
|
||
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|
||
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
|
||
|
||
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
|
||
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|
||
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
|
||
grep -q 'ValidateConfigValue' apps/controller/config.go \
|
||
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
|
||
|
||
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
|
||
grep -q 'func WithConfigTx' apps/dao/config.go \
|
||
|| fail "acceptance: у записи настроек нет транзакционной границы"
|
||
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|
||
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
|
||
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|
||
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
|
||
# Проверка обязана завершиться ДО первой записи.
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
|
||
const validate = source.indexOf("service.ValidateConfigValue(");
|
||
const apply = source.indexOf("service.UpdateConfigs(updates)");
|
||
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
|
||
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
|
||
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
|
||
}
|
||
|
||
# Мёртвые ключи таблицы `config` не должны вернуться.
|
||
run_dead_config_acceptance() {
|
||
log_step "Acceptance: dead config keys stay removed"
|
||
|
||
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
|
||
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
|
||
# другое обязано называть их по имени.
|
||
local dead_key hits
|
||
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
|
||
hits="$(code_mentions_in "$dead_key" \
|
||
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
|
||
[ -z "$hits" ] \
|
||
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
|
||
done
|
||
|
||
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|
||
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
|
||
|
||
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
|
||
# была вторым источником истины и читалась ПЕРВОЙ.
|
||
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
|
||
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
|
||
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
|
||
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
|
||
|
||
log_step "Acceptance: share URI remark is derived, not stored as dead state"
|
||
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|
||
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
|
||
}
|
||
|
||
# Гигиена зависимостей и bootstrap-учётных данных.
|
||
run_dependency_hygiene_acceptance() {
|
||
local package_dir="$1"
|
||
|
||
log_step "Acceptance: bootstrap credentials are never invented or logged"
|
||
|
||
# Ключевой канал утечки: сгенерированный пароль печатался в
|
||
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
|
||
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
|
||
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
|
||
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|
||
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
|
||
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|
||
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
|
||
|
||
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
|
||
grep -q 'func ExportAdminLog' apps/service/journal.go \
|
||
|| fail "acceptance: журнал админки выгружается без санитайза"
|
||
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
|
||
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
|
||
|
||
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
|
||
local jwt_v3_hits
|
||
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
|
||
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
|
||
[ -z "$jwt_v3_hits" ] \
|
||
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
|
||
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|
||
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
|
||
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|
||
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
|
||
|
||
log_step "Acceptance: password storage has exactly one format"
|
||
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
|
||
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
|
||
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|
||
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
|
||
|
||
log_step "Acceptance: the release is built with a dependency vulnerability gate"
|
||
[ -f tools/build/lib/security.sh ] \
|
||
|| fail "acceptance: модуль проверки зависимостей отсутствует"
|
||
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|
||
|| fail "acceptance: сборка не запускает проверку зависимостей"
|
||
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|
||
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
|
||
|
||
# По готовому tarball должно быть видно, что он проверялся — ровно как
|
||
# hysteria_compat_gate.
|
||
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|
||
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
|
||
|
||
# У шага не должно быть обходов — ни объявленных, ни забытых.
|
||
#
|
||
# Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata
|
||
# `accepted-risk`, SKIP_SECURITY_SCAN=true — `skipped`. Оба документировались
|
||
# README и docs/02 как способ выпустить релиз, зная об уязвимости, и оба
|
||
# гарантированно падали здесь же, строкой выше: приёмка требует буквально
|
||
# `true`. Сборка проходила весь цикл и умирала на последнем шаге ради
|
||
# операции, которую продукт в итоге запрещает.
|
||
#
|
||
# Противоречие закрыто в пользу строгой политики, и теперь это проверяется, а
|
||
# не только описано. Ищется КОД: комментарий выше объясняет, почему обходов
|
||
# нет, и обязан называть их по имени.
|
||
local bypass_var bypass_hits
|
||
for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN; do
|
||
bypass_hits="$(code_mentions_in "$bypass_var" tools/build)"
|
||
[ -z "$bypass_hits" ] \
|
||
|| fail "acceptance: обход проверки зависимостей $bypass_var вернулся в: $bypass_hits"
|
||
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: reconfigure classifies by ownership, not by message text"
|
||
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|
||
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
|
||
grep -q 'classifyReconfigureFailure(ownership)' orchestrator/src/commands/reconfigure.ts \
|
||
|| fail "acceptance: reconfigure failure classification must be ownership-driven"
|
||
}
|
||
|
||
# Каналы утечки секретов: Config API и журналы.
|
||
run_secret_channel_acceptance() {
|
||
log_step "Acceptance: config API is an allowlist, not a denylist"
|
||
grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \
|
||
|| fail "acceptance: config reads must go through the allowlist"
|
||
grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \
|
||
|| fail "acceptance: config writes must go through the allowlist"
|
||
# Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ
|
||
# getConfig — он проверяет, что маршрут не вернулся.
|
||
! grep -rq 'controller.GetConfig' apps/router \
|
||
|| fail "acceptance: the arbitrary-key getConfig route came back"
|
||
! grep -q 'func GetConfig(' apps/controller/config.go \
|
||
|| fail "acceptance: the arbitrary-key getConfig handler came back"
|
||
! grep -rq 'export function getConfigApi' apps/frontend/src \
|
||
|| fail "acceptance: the frontend client for getConfig came back"
|
||
! grep -rq 'getConfigApi(' apps/frontend/src \
|
||
|| fail "acceptance: something still calls the removed getConfig client"
|
||
local secret_key
|
||
for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do
|
||
grep -q "${secret_key}," apps/model/constant/config.go \
|
||
|| fail "acceptance: ${secret_key} must be declared an internal config key"
|
||
done
|
||
# Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись.
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8");
|
||
const start = source.indexOf("publicReadableConfigKeys");
|
||
const end = source.indexOf("func IsPublicReadableConfigKey");
|
||
const body = source.slice(start, end);
|
||
for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) {
|
||
if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist");
|
||
}
|
||
' || fail "acceptance: no secret key may appear in the public config allowlist"
|
||
|
||
log_step "Acceptance: request logging never carries query values"
|
||
# Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер
|
||
# больше не пишет RequestURI, обязан быть разрешён. Поэтому строки
|
||
# комментариев отбрасываются до поиска.
|
||
local log_code server_code
|
||
log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)"
|
||
server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)"
|
||
|
||
printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \
|
||
|| fail "acceptance: the request logger must log the path, not RequestURI"
|
||
! printf '%s\n' "$log_code" | grep -q 'RequestURI' \
|
||
|| fail "acceptance: RequestURI carries the machine token in its query string"
|
||
! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \
|
||
|| fail "acceptance: the reqUri log field came back"
|
||
|
||
log_step "Acceptance: the admin has exactly one HTTP log channel"
|
||
! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \
|
||
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
|
||
printf '%s\n' "$server_code" | grep -q 'gin.New()' \
|
||
|| fail "acceptance: the admin engine must be built with gin.New()"
|
||
printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \
|
||
|| fail "acceptance: gin.New() must still install the recovery middleware"
|
||
|
||
log_step "Acceptance: exported logs are sanitized on both sides"
|
||
grep -q 'SanitizeLogText' apps/service/journal.go \
|
||
|| fail "acceptance: exported Hysteria journal must be sanitized"
|
||
grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \
|
||
|| fail "acceptance: diagnostics bundle must sanitize collected journals"
|
||
grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \
|
||
|| fail "acceptance: the admin journal must be part of the sanitized set"
|
||
|
||
log_step "Acceptance: machine token comparison is constant time"
|
||
grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \
|
||
|| fail "acceptance: the machine token must be compared in constant time"
|
||
}
|
||
|
||
# Импорт пиров: одна транзакция и ровно один JSON-документ.
|
||
run_atomic_import_acceptance() {
|
||
log_step "Acceptance: peer import is a single database transaction"
|
||
grep -q 'WithPeerTx' apps/service/peer.go \
|
||
|| fail "acceptance: peer import must apply the whole batch in one transaction"
|
||
grep -q 'func WithPeerTx' apps/dao/peer.go \
|
||
|| fail "acceptance: the peer transaction boundary is missing from the dao layer"
|
||
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|
||
|| fail "acceptance: WithPeerTx must open a real database transaction"
|
||
# Применение обязано идти по tx, а не по глобальному соединению.
|
||
#
|
||
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
|
||
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
|
||
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
|
||
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
|
||
# которым глобальное соединение положено. Проверка гарантированно падала на
|
||
# корректном коде и не была замечена только потому, что сборка до неё не
|
||
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
|
||
#
|
||
# Границей тела функции служит следующее объявление верхнего уровня.
|
||
"$BUN_BIN" -e '
|
||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||
const start = source.indexOf("func applyPeerImportEntry");
|
||
if (start < 0) throw new Error("applyPeerImportEntry is missing");
|
||
const rest = source.slice(start + 1);
|
||
const end = rest.indexOf("\nfunc ");
|
||
const body = end < 0 ? rest : rest.slice(0, end);
|
||
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
|
||
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
|
||
}
|
||
' || fail "acceptance: peer import must not bypass the transaction"
|
||
|
||
log_step "Acceptance: peer import accepts exactly one JSON document"
|
||
grep -q 'io.EOF' apps/controller/peer.go \
|
||
|| fail "acceptance: peer import must verify that nothing follows the JSON document"
|
||
grep -q 'exactly one JSON document' apps/controller/peer.go \
|
||
|| fail "acceptance: the trailing-document refusal must be explicit"
|
||
|
||
log_step "Acceptance: peer export offers both modes in the UI"
|
||
grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \
|
||
|| fail "acceptance: the UI must be able to request a secrets-bearing backup"
|
||
grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \
|
||
|| fail "acceptance: the backup export button is missing"
|
||
grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \
|
||
|| fail "acceptance: a secrets-bearing export must require explicit confirmation"
|
||
local locale
|
||
for locale in ru en; do
|
||
grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \
|
||
|| fail "acceptance: the backup warning is missing from the ${locale} locale"
|
||
done
|
||
}
|
||
|
||
# Compatibility-слой предыдущего поколения не должен пережить 1.0.
|
||
run_legacy_account_acceptance() {
|
||
log_step "Acceptance: the legacy account layer is gone from the runtime"
|
||
[ ! -f apps/model/entity/account.go ] \
|
||
|| fail "acceptance: the LegacyAccount entity came back"
|
||
# Каталоги перечислены явно: рекурсия по apps захватила бы
|
||
# apps/frontend/node_modules, который к этому шагу сборки уже существует.
|
||
! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \
|
||
|| fail "acceptance: legacy account migration code came back"
|
||
# Ищется запись в списке миграций (имя в кавычках), а не упоминание:
|
||
# комментарий, объясняющий, почему миграции удалены, должен быть разрешён.
|
||
local dead_migration
|
||
for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do
|
||
! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \
|
||
|| fail "acceptance: legacy migration ${dead_migration} came back"
|
||
done
|
||
# Номера оставшихся миграций не перенумеровываются: они уже записаны в
|
||
# schema_migrations на установленных машинах.
|
||
local kept_migration
|
||
for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do
|
||
grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \
|
||
|| fail "acceptance: migration ${kept_migration} disappeared or was renumbered"
|
||
done
|
||
|
||
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
|
||
# docs/14 — единственное место, где эти имена обозначают реальные объекты
|
||
# для удаления. В обычных docs их быть не должно.
|
||
local doc
|
||
for doc in docs/*.md; do
|
||
case "$doc" in
|
||
docs/14-legacy-cleanup.md) continue ;;
|
||
esac
|
||
! grep -q 'H_UI_' "$doc" \
|
||
|| fail "acceptance: previous-generation config keys leaked into $doc"
|
||
done
|
||
}
|