diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 8cac914..56c1bf0 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -134,4 +134,109 @@ run_fix20_acceptance_subset() { 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: 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" + # Ищется регистрация маршрута (имя в кавычках), а не любое упоминание: + # комментарий, объясняющий, почему маршрута нет, должен быть разрешён. + local dead_route + for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath; do + ! grep -rqF "${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" } diff --git a/tools/legacy/purge-v0.sh b/tools/legacy/purge-v0.sh new file mode 100644 index 0000000..639c084 --- /dev/null +++ b/tools/legacy/purge-v0.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Очистка сервера от установки HY2XS предыдущего поколения (0.x) и от +# незавершённой установки v1. +# +# Почему это отдельный скрипт, а не шаг установщика: +# +# HY2XS v1 принципиально не поддерживает миграцию. Встроенная в installer +# очистка вернула бы destructive migration logic обратно в путь свежей +# установки — ровно то, от чего мы ушли. Установщик обязан обнаружить старую +# установку, НИЧЕГО не изменить и отправить оператора сюда. +# +# Скрипт разрушительный. По умолчанию он ничего не делает: показывает план. +# +# sudo ./purge-v0.sh # план (dry-run) +# sudo ./purge-v0.sh --apply --yes-i-know # выполнить +# +# ВАЖНО: скрипт удаляет базу админки вместе с учётными записями пиров. +# Ссылки, выданные пользователям, перестанут работать. + +APPLY="false" +CONFIRMED="false" +KEEP_HYSTERIA_BINARY="false" + +log() { printf '[hy2xs-purge] %s\n' "$*"; } +warn() { printf '[hy2xs-purge] WARNING: %s\n' "$*" >&2; } +fail() { printf '[hy2xs-purge] ERROR: %s\n' "$*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +Usage: purge-v0.sh [--apply --yes-i-know] [--keep-hysteria-binary] + + (без флагов) показать план очистки и выйти + --apply выполнить очистку + --yes-i-know подтверждение: обязателен вместе с --apply + --keep-hysteria-binary не удалять /usr/local/bin/hysteria + +Скрипт удаляет службы, приложение, конфигурацию и БАЗУ ДАННЫХ админки. +EOF + exit 2 +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --apply) APPLY="true"; shift ;; + --yes-i-know) CONFIRMED="true"; shift ;; + --keep-hysteria-binary) KEEP_HYSTERIA_BINARY="true"; shift ;; + -h|--help) usage ;; + *) fail "unknown argument: $1" ;; + esac + done + + if [ "$APPLY" = "true" ] && [ "$CONFIRMED" != "true" ]; then + fail "--apply requires an explicit --yes-i-know confirmation" + fi +} + +UNITS=( + hysteria-server.service + hy2xs-admin.service + h-ui.service +) + +UNIT_FILES=( + /etc/systemd/system/hysteria-server.service + /etc/systemd/system/hy2xs-admin.service + /etc/systemd/system/h-ui.service +) + +PATHS=( + /etc/hy2xs + /etc/hysteria + /var/lib/hy2xs + /var/lib/hy2xs-admin + /var/lib/hysteria + /var/log/hy2xs + /opt/hy2xs-admin + /usr/local/lib/hy2xs + /usr/local/h-ui + /usr/local/bin/hy2xs-orchestrator +) + +NFT_FRAGMENT=/etc/nftables.d/hy2xs.nft + +require_root() { + [ "$(id -u)" = "0" ] || fail "purge-v0.sh must run as root" +} + +# Таймеры отката firewall переживают неудачную установку и продолжат менять +# ruleset уже после очистки, если их не снять. +rollback_guard_units() { + systemctl list-units --all --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \ + | awk '{print $1}' \ + | grep -E '^hy2xs-fw-rollback-' || true +} + +show_plan() { + log "будут остановлены и выключены службы:" + local unit + for unit in "${UNITS[@]}"; do + printf ' - %s\n' "$unit" + done + + local guard + guard="$(rollback_guard_units)" + if [ -n "$guard" ]; then + log "будут сняты таймеры отката firewall:" + printf ' - %s\n' $guard + fi + + log "будут удалены пути:" + local path + for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "$NFT_FRAGMENT"; do + if [ -e "$path" ]; then + printf ' - %s (существует)\n' "$path" + else + printf ' - %s (отсутствует)\n' "$path" + fi + done + + if [ "$KEEP_HYSTERIA_BINARY" = "true" ]; then + log "/usr/local/bin/hysteria будет сохранён (--keep-hysteria-binary)" + else + log "будет удалён /usr/local/bin/hysteria" + fi + + log "из /etc/nftables.conf будет убрана строка include для $NFT_FRAGMENT" + warn "БАЗА ДАННЫХ админки (/var/lib/hy2xs-admin) удаляется: пиры и их ссылки будут потеряны" + warn "sshd, сеть и правила firewall вне фрагмента HY2XS не изменяются" +} + +stop_units() { + local unit + for unit in "${UNITS[@]}"; do + systemctl stop "$unit" >/dev/null 2>&1 || true + systemctl disable "$unit" >/dev/null 2>&1 || true + systemctl reset-failed "$unit" >/dev/null 2>&1 || true + done + + local guard + guard="$(rollback_guard_units)" + if [ -n "$guard" ]; then + local guard_unit + for guard_unit in $guard; do + systemctl stop "$guard_unit" >/dev/null 2>&1 || true + systemctl disable "$guard_unit" >/dev/null 2>&1 || true + systemctl reset-failed "$guard_unit" >/dev/null 2>&1 || true + rm -f "/etc/systemd/system/$guard_unit" + done + fi +} + +remove_paths() { + local path + for path in "${UNIT_FILES[@]}"; do + rm -f "$path" + done + + systemctl daemon-reload || true + + for path in "${PATHS[@]}"; do + rm -rf "$path" + done + + if [ "$KEEP_HYSTERIA_BINARY" != "true" ]; then + rm -f /usr/local/bin/hysteria + fi +} + +# Из /etc/nftables.conf убирается только include HY2XS: остальной ruleset +# принадлежит оператору и удалению не подлежит. +detach_nftables_fragment() { + rm -f "$NFT_FRAGMENT" + + if [ -f /etc/nftables.conf ] && grep -q 'nftables.d/hy2xs.nft' /etc/nftables.conf; then + local tmp + tmp="$(mktemp)" + grep -v 'nftables.d/hy2xs.nft' /etc/nftables.conf >"$tmp" + cat "$tmp" >/etc/nftables.conf + rm -f "$tmp" + log "убрана строка include HY2XS из /etc/nftables.conf" + fi + + if command -v nft >/dev/null 2>&1 && [ -f /etc/nftables.conf ]; then + if nft -c -f /etc/nftables.conf >/dev/null 2>&1; then + nft -f /etc/nftables.conf >/dev/null 2>&1 || warn "не удалось перезагрузить /etc/nftables.conf" + else + warn "/etc/nftables.conf не проходит проверку nft -c; ruleset не перезагружен" + fi + fi +} + +# Финальная проверка повторяет clean-host контракт установщика: если она не +# прошла, следующая установка всё равно откажется, и лучше узнать об этом здесь. +verify_clean_host() { + local leftovers=() + local path + + for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "$NFT_FRAGMENT"; do + [ -e "$path" ] && leftovers+=("$path") + done + + if [ "$KEEP_HYSTERIA_BINARY" != "true" ] && [ -e /usr/local/bin/hysteria ]; then + leftovers+=(/usr/local/bin/hysteria) + fi + + local unit + for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --no-legend "$unit" 2>/dev/null | grep -q .; then + leftovers+=("systemd unit: $unit") + fi + done + + if [ "${#leftovers[@]}" -gt 0 ]; then + warn "после очистки остались объекты:" + for path in "${leftovers[@]}"; do + printf ' - %s\n' "$path" >&2 + done + fail "хост не является чистым; удалите перечисленное вручную" + fi + + log "проверка пройдена: хост чист для установки HY2XS v1" +} + +main() { + parse_args "$@" + require_root + + show_plan + + if [ "$APPLY" != "true" ]; then + log "это был dry-run; для выполнения запустите: --apply --yes-i-know" + return 0 + fi + + log "выполняю очистку" + stop_units + detach_nftables_fragment + remove_paths + systemctl daemon-reload || true + verify_clean_host + + log "готово. Теперь можно устанавливать HY2XS v1 с нуля." +} + +main "$@"