Files
HY2XS_flamy/tools/build/lib/verify.sh
T
founder 672d455467 fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

178 lines
11 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
require_repo_layout() {
[ -f "LICENSE" ] || fail "missing LICENSE"
[ -f "versions.env" ] || fail "missing versions.env (product/platform/toolchain contract)"
[ -d "orchestrator/src" ] || fail "missing orchestrator/src"
[ -f "orchestrator/package.json" ] || fail "missing orchestrator/package.json"
[ -f "orchestrator/bun.lock" ] || fail "missing orchestrator/bun.lock"
[ -f "package/install.sh" ] || fail "missing package/install.sh"
[ -d "package/templates" ] || fail "missing package/templates"
[ -f "package/config/hy2xs.env" ] || fail "missing package/config/hy2xs.env"
[ -f "package/templates/env/post-install.env.tpl" ] || fail "missing package/templates/env/post-install.env.tpl"
[ -d "package/systemd" ] || fail "missing package/systemd"
[ -d "apps" ] || fail "missing apps HY2XS admin source"
[ -f "apps/go.mod" ] || fail "missing apps/go.mod"
[ -f "apps/go.sum" ] || fail "missing apps/go.sum"
[ -f "apps/frontend/package.json" ] || fail "missing apps/frontend/package.json"
[ -f "apps/frontend/pnpm-lock.yaml" ] || fail "missing apps/frontend/pnpm-lock.yaml"
}
verify_source_tree_policy() {
local allow_dirty="${ALLOW_DIRTY_BUILD:-false}"
local dirty="false"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
fail "build must run inside git work tree"
fi
if [ -n "$(git status --porcelain 2>/dev/null || true)" ]; then
dirty="true"
fi
if [ "$dirty" = "true" ] && [ "$allow_dirty" != "true" ]; then
fail "dirty git tree is not allowed for production build; set ALLOW_DIRTY_BUILD=true to override"
fi
}
verify_archive() {
local version="$1"
local archive="dist/hy2xs-install-${version}.tar.gz"
[ -f "$archive" ] || fail "archive was not created: $archive"
local listing
listing="$(tar -tzf "$archive")"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/install.sh$' || fail "archive missing install.sh"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/LICENSE$' || fail "archive missing LICENSE"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/orchestrator/hy2xs-orchestrator$' || fail "archive missing orchestrator artifact"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/ui/hy2xs-admin/hy2xs-admin$' || fail "archive missing bundled UI binary"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hysteria-server.service$' || fail "archive missing hysteria systemd unit"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hy2xs-admin.service$' || fail "archive missing admin systemd unit"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/hysteria/config.yaml.tpl$' || fail "archive missing Hysteria config template"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/config/hy2xs.env$' || fail "archive missing canonical runtime config"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/env/post-install.env.tpl$' || fail "archive missing post-install env template"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/checksums.txt$' || fail "archive missing checksums"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.version$' || fail "archive missing pinned Hysteria version"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.url$' || fail "archive missing pinned Hysteria artifact url"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.sha256$' || fail "archive missing pinned Hysteria sha256"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.resolution$' || fail "archive missing Hysteria resolution marker"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.release_line$' || fail "archive missing product release line"
printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.config_schema_version$' || fail "archive missing packaged config schema version"
# Контракт установки проверяется структурно: install.sh — read-only bootstrap
# целиком, у PHASE 1 ровно один владелец — оркестратор.
#
# Раньше здесь проверялся порядок «preflight раньше первой мутации», и сама
# проверка ТРЕБОВАЛА наличия мутирующей строки в install.sh. Пока shell сам
# раскладывал оркестратор и runtime-пакет, между двумя фазами существовало
# окно: PHASE 0 проходила, install.sh изменял хост, а отказ следующего
# preflight внутри оркестратора объявлялся «на сервере ничего не изменено».
# Владение мутацией нельзя было отследить, потому что мутировали двое.
local packaged_installer mutation_hits
packaged_installer="$(tar -xOzf "$archive" hy2xs-install/install.sh)"
printf '%s\n' "$packaged_installer" | grep -q 'preflight-install' \
|| fail "packaged install.sh does not run the read-only preflight"
printf '%s\n' "$packaged_installer" | grep -q 'preflight-install --package-dir' \
|| fail "packaged install.sh: could not locate the preflight invocation"
# Комментарии отбрасываются: строка, ОБЪЯСНЯЮЩАЯ, почему установщик больше
# не выполняет `install -d`, не является выполнением `install -d`.
mutation_hits="$(printf '%s\n' "$packaged_installer" \
| grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \
|| true)"
[ -z "$mutation_hits" ] \
|| fail "packaged install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone. Found:
$mutation_hits"
printf '%s\n' "$packaged_installer" | grep -q 'exec .*install --package-dir' \
|| fail "packaged install.sh must hand the whole mutating phase to the orchestrator via exec"
local license_text
license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)"
printf '%s\n' "$license_text" | grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' \
|| fail "packaged LICENSE is not the GNU Affero General Public License"
printf '%s\n' "$license_text" | grep -q 'Version 3, 19 November 2007' \
|| fail "packaged LICENSE is not AGPL version 3"
local env_content
env_content="$(tar -xOzf "$archive" hy2xs-install/config/hy2xs.env)"
printf '%s\n' "$env_content" | grep -q 'replace-with-your-domain.example' && fail "packaged hy2xs.env contains placeholder domain"
printf '%s\n' "$env_content" | grep -q 'replace-with-your-email@example.com' && fail "packaged hy2xs.env contains placeholder email"
local hysteria_tpl
hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.tpl)"
printf '%s\n' "$hysteria_tpl" | grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token"
printf '%s\n' "$hysteria_tpl" | grep -q '{{OBFS_BLOCK}}' || fail "hysteria template must render a typed obfs block"
# Замороженная версия обязана совпадать во всех местах пакета.
local pinned_version pinned_sha pinned_url meta_env
pinned_version="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.version | tr -d '\n')"
pinned_sha="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.sha256 | tr -d '\n')"
pinned_url="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.url | tr -d '\n')"
meta_env="$(tar -xOzf "$archive" hy2xs-install/metadata/package.env)"
printf '%s' "$pinned_version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "packaged Hysteria version is not a concrete vX.Y.Z: $pinned_version"
printf '%s' "$pinned_sha" | grep -Eq '^[a-f0-9]{64}$' \
|| fail "packaged Hysteria sha256 is not a 64-char hex digest"
printf '%s' "$pinned_url" | grep -Fq "$pinned_version" \
|| fail "packaged Hysteria artifact url does not point at the pinned version: $pinned_url"
if printf '%s' "$pinned_url" | grep -q 'latest'; then
fail "packaged Hysteria artifact url must be immutable, not a moving latest: $pinned_url"
fi
printf '%s\n' "$meta_env" | grep -q "^hysteria_version=${pinned_version}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.version"
printf '%s\n' "$meta_env" | grep -q "^hysteria_artifact_sha256=${pinned_sha}$" \
|| fail "metadata package.env disagrees with metadata/hysteria.sha256"
local post_install_tpl
post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)"
printf '%s\n' "$post_install_tpl" | grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' || fail "post-install env template must include machine access_token in HY2_AUTH_URL"
local tmp
tmp="$(mktemp -d)"
tar -xzf "$archive" -C "$tmp"
[ -x "$tmp/hy2xs-install/install.sh" ] || fail "install.sh is not executable"
[ -x "$tmp/hy2xs-install/orchestrator/hy2xs-orchestrator" ] || fail "orchestrator is not executable"
[ -x "$tmp/hy2xs-install/ui/hy2xs-admin/hy2xs-admin" ] || fail "hy2xs-admin is not executable"
"$tmp/hy2xs-install/orchestrator/hy2xs-orchestrator" status --package-dir "$tmp/hy2xs-install" >/dev/null 2>&1 || fail "orchestrator status sanity check failed"
local meta
meta="$(cat "$tmp/hy2xs-install/metadata/package.env")"
printf '%s\n' "$meta" | grep -q '^source_git_commit=' || fail "metadata missing source_git_commit"
printf '%s\n' "$meta" | grep -q '^dirty_tree=' || fail "metadata missing dirty_tree"
printf '%s\n' "$meta" | grep -q '^build_profile=production$' || fail "metadata missing build_profile=production"
printf '%s\n' "$meta" | grep -q '^license=AGPL-3.0-only$' || fail "metadata missing license=AGPL-3.0-only"
# Поколение продукта обязано доехать до пакета: install-state строится из
# него, а reconfigure/repair по нему отличают v1 от чужой установки.
printf '%s\n' "$meta" | grep -q "^version=${HY2XS_VERSION}$" \
|| fail "metadata version disagrees with versions.env HY2XS_VERSION=${HY2XS_VERSION}"
printf '%s\n' "$meta" | grep -q "^release_line=${HY2XS_RELEASE_LINE}$" \
|| fail "metadata release_line disagrees with versions.env HY2XS_RELEASE_LINE=${HY2XS_RELEASE_LINE}"
printf '%s\n' "$meta" | grep -q "^config_schema_version=${HY2XS_CONFIG_SCHEMA_VERSION}$" \
|| fail "metadata config_schema_version disagrees with versions.env"
printf '%s\n' "$meta" | grep -q "^target_version=${HY2XS_TARGET_OS_VERSION}$" \
|| fail "metadata target_version disagrees with versions.env"
printf '%s\n' "$meta" | grep -q "^target_arch=${HY2XS_TARGET_ARCH}$" \
|| fail "metadata target_arch disagrees with versions.env"
local packaged_schema
packaged_schema="$(grep -E '^HY2XS_CONFIG_SCHEMA_VERSION=' "$tmp/hy2xs-install/config/hy2xs.env" | head -n1 | cut -d= -f2-)"
[ "$packaged_schema" = "$HY2XS_CONFIG_SCHEMA_VERSION" ] \
|| fail "packaged hy2xs.env declares schema $packaged_schema, versions.env declares $HY2XS_CONFIG_SCHEMA_VERSION"
verify_admin_version_contract "$tmp/hy2xs-install/ui/hy2xs-admin/hy2xs-admin"
if declare -F run_fix20_acceptance_subset >/dev/null 2>&1; then
run_fix20_acceptance_subset "$tmp/hy2xs-install"
fi
rm -rf "$tmp"
}