diff --git a/CHANGELOG.md b/CHANGELOG.md index 624587b..b673223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,106 @@ Hardening-проход перед релизом `1.0.0`. Основная те состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст графа зависимостей. +Третий проход — операции, которые делают не то, что обещает их имя: отзыв +доступа, не переживающий перезапуск; резервная копия, молча получающаяся +неполной; диагностика, обрывающая соединения; аварийный выход сборки, которым +невозможно воспользоваться. + +### Исправлено — операции, не выполняющие обещанного + +- **Удаление `bootstrap-admin-peer` не было отзывом доступа.** Признаком + «создавать пир или нет» служило наличие строки в таблице пиров, а + `HY2XS_ADMIN_CON_PASS` продолжает жить в `/etc/hy2xs/hy2xs.env` — его читает + systemd-юнит. Поэтому оператор удалял пира, доступ действительно исчезал, а + ближайший `systemctl restart hy2xs-admin` или перезагрузка сервера возвращали + того же пира с тем же секретом. Молча: ни строки в журнале, в списке пиров + запись просто снова есть. + + Признаком стала отметка `BOOTSTRAP_PEER_SEEDED` в таблице `config`: она + отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас». + Отметка и сам пир пишутся одной транзакцией — раздельная запись вернула бы + прежнее поведение при падении процесса между двумя операциями. Удаление + осталось разрешённым и стало необратимым; отключение (`Disabled = 1`) + остаётся вторым, обратимым способом. + +- **Резервная копия с секретами могла молча оказаться неполной.** + `ListExportPeer(true)` проглатывала и ошибку расшифровки, и отсутствие + шифртекста, отдавая пира с пустым полем `secret` и успешный ответ. Оператор, + СПЕЦИАЛЬНО выбравший режим «копия с действующими credentials», получал файл, + выглядящий полным, и узнавал о потере после импорта на новом сервере — по + отвалившимся клиентам. Теперь недоступный секрет хотя бы одного пира + отклоняет весь запрос с указанием имени; файл не создаётся. Безопасная + выгрузка (`includeSecrets=false`) не изменилась. + +- **`DecryptPeerSecret` возвращала содержимое колонки как расшифрованный + секрет,** если оно не начиналось с `v1:`. Ветка досталась от поколения, где + секреты пиров лежали открытым текстом; при clean-install-only политике такой + строки существовать не может, а вред оставался: повреждённая колонка уходила + в клиентскую ссылку и в резервную копию как учётные данные. Формат хранения + теперь ровно один, всё остальное — ошибка. Тот же класс, что и удалённый + SHA-224 fallback при входе. + +- **`hy2xs-orchestrator doctor` перезапускал оба сервиса.** Команда собирала + контекст с параметрами по умолчанию и звала общий smoke, который начинается с + `systemctl restart hysteria-server hy2xs-admin`. То есть диагностика, которую + runbook предлагает запускать при подозрении на проблему, гарантированно + обрывала все живые VPN-соединения — включая случай, когда с сервисом всё в + порядке. Диагностика, меняющая то, что диагностирует, отвечает не на заданный + вопрос. `doctor` принудительно выставляет `skipServiceStart`; остальные + проверки smoke выполняются полностью. + +- **Админка сама придумывала `HYSTERIA2_TRAFFIC_STATS_SECRET`.** При пустом env + и пустой базе она генерировала случайный токен, записать который в + `/etc/hysteria/config.yaml` не может — файл принадлежит оркестратору и + доступен ей только на чтение. Сервис объявлял себя здоровым, а machine auth + переставал совпадать, потому что Hysteria продолжала слать прежнее значение. + Тот же принцип, что уже действует для `HY2XS_ADMIN_INITIAL_PASSWORD`: пустой + env при пустой базе — отказ старта; уже согласованный токен в базе + принимается. + +- **Обходы проверки зависимостей существовали только на бумаге.** + Документированные `dependency_security_gate=accepted-risk` и `skipped` не + могли произвести артефакт: финальная приёмка архива требует буквально `true`, + поэтому сборка с ними проходила весь цикл и падала на последнем шаге. Продукт + документировал операцию, которую сам же запрещал. Обе переменные удалены из + сборки и документации; их отсутствие проверяется приёмкой. Контракт стал + однозначным: релизный артефакт невозможно собрать с непройденной проверкой. + +- **`UPDATE` по отсутствующей строке `config` считался успехом.** + `updateConfigOn` смотрел только на `tx.Error`, а UPDATE без совпавших строк + ошибкой SQL не является. Следом `applyRuntimeConfigUpdates` применял значение + к планировщику, поэтому оператор дважды получал подтверждение изменения, + которого не произошло, а перезапуск сервиса возвращал прежний cron. Решение + принимается по `RowsAffected` — как в соседней `UpsertConfigValue`, где эта же + ошибка уже была разобрана. + +- **Слой данных не отличал «записи нет» от «база не ответила».** Каждый `Get*` + возвращал свежий `errors.New` со строкой, поэтому отказ SQLite был + неотличим от отсутствия записи, а решения на этом принимались серьёзные: + «пира нет» означало «создать заново», «по auth_id не нашли» — «искать по + имени и, не найдя, создать», «ошибка» в `ExistPeerName` — «имя свободно». При + недоступной базе продукт не отказывал, а трактовал отказ как разрешение + действовать. Введены sentinel-значения `ErrPeerNotFound`, + `ErrAdminUserNotFound`, `ErrConfigNotFound` и `ErrStorage`. + +- **Алиасы `/:id/client-url` и `/:id/qr` удалены.** Они были оставлены «на один + миграционный релиз», которого у clean-install-only продукта не существует; + дожив до `1.0.0`, они стали бы частью публичного API v1. + +### Добавлено — контракт разработки + +- **`apps/go.mod` объявляет `toolchain go1.26.7`.** Директива `go` — языковой + baseline модуля, а не выбор компилятора: с ней одной локальный `go build` на + другой минорной линии проходил успешно, хотя релизный бинарь собирается на + 1.26.7 и наследует **её** stdlib. Разработчик и сборка проверяли разный код. + Совпадение с `versions.env` проверяет `verify_go_toolchain_contract`. + +- **`tools/dev/doctor.sh` и `tools/dev/doctor.ps1`** сверяют Go, Node, pnpm, Bun + и директиву `toolchain` с `versions.env`. Собственных значений версий у них + нет — второй список неизбежно разъехался бы с контрактом. Сборка соблюдала + `versions.env` и раньше; машина разработчика не проверялась никак, и + расхождение обнаруживалось на Debian, внутри release-сборки. + ### Исправлено — сборка не собиралась - **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.** diff --git a/README.md b/README.md index cf91606..af2c15f 100644 --- a/README.md +++ b/README.md @@ -701,7 +701,7 @@ hy2xs-orchestrator reconfigure \ | --- | --- | | `hy2xs-orchestrator preflight-install` | Read‑only проверка чистоты хоста; ничего не меняет | | `hy2xs-orchestrator status` | Показать состояние платформы, сервисов, firewall и install marker | -| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки | +| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки; сервисы **не перезапускает** | | `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения | | `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию | | `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** | @@ -872,6 +872,43 @@ hy2xs-orchestrator redact-config \ Обычному пользователю не нужно собирать проект из исходников. Этот раздел нужен maintainer’у, который готовит release‑архив. +### Перед работой: сверьте среду с контрактом + +```bash +./tools/dev/doctor.sh # Linux/macOS +.\tools\dev\doctor.ps1 # Windows (PowerShell 7+) +``` + +```text +HY2XS development environment +contract: versions.env (HY2XS 1.0.0, release line 1) + +Go: + required: 1.26.7 + found: 1.25.6 + FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7 + +Node: + required: 24.20.0 + found: 24.20.0 + OK +``` + +Скрипт ничего не устанавливает и не меняет — он отвечает на один вопрос: +совпадает ли эта машина с контрактом сборки. + +Раньше `versions.env` был контрактом только для сборки: она скачивает Go, Node и +Bun ровно тех версий, что там записаны, сверяя контрольные суммы, а машина +разработчика не проверялась никак. Расхождение обнаруживалось на Debian, внутри +release‑сборки, и выглядело как «у меня работало». + +Расхождение не гипотетическое. Директива `go` в `apps/go.mod` — это языковой +baseline модуля, а не выбор компилятора, поэтому локальный Go другой минорной +линии собирал проект успешно, пока релизный бинарь компилировался на 1.26.7 и +наследовал **её** stdlib: проверялся не тот код, который уезжает в production. +Поэтому `go.mod` теперь объявляет `toolchain` явно, а `doctor` показывает +расхождение до сборки, а не после. + Сборка поддерживается на Debian 13 amd64 из чистого git work tree. ```bash @@ -935,12 +972,21 @@ export GITHUB_TOKEN= | `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass | | `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл | -Переменные проверки зависимостей: +Проверка зависимостей переменными не управляется: у неё **нет аварийного +выхода**. Релизный артефакт HY2XS невозможно собрать с непройденным гейтом, и +поле `dependency_security_gate` в `metadata/package.env` принимает единственное +значение `true`. -| Переменная | По умолчанию | Назначение | -| --- | --- | --- | -| `ALLOW_VULNERABLE_DEPENDENCIES` | `false` | Выпустить релиз, зная о найденной уязвимости. Решение человека, а не поведение по умолчанию; попадает в metadata как `dependency_security_gate=accepted-risk` | -| `SKIP_SECURITY_SCAN` | `false` | Полностью пропустить шаг; в metadata пишется `skipped` | +Раньше здесь были описаны два способа выпустить релиз, зная об уязвимости. Ими +они не являлись: финальная приёмка архива требует буквально +`dependency_security_gate=true`, поэтому сборка с любым из них проходила весь +цикл и падала на последнем шаге. Документированная операция, которую продукт сам +же запрещает, — хуже отсутствующей. + +Если advisory вышло в неудачный момент, чинится это обновлением графа +(`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в +`versions.env`. Для локальной работы обходить нечего: `go test ./...`, +`govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают. Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы. Первая следит, что зафиксированные версии **согласованы между собой**; вторая — @@ -981,6 +1027,7 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \ ├── orchestrator/ # install-only orchestrator на Bun + TypeScript ├── package/ # skeleton будущего install package ├── tools/build/ # production builder и packaging pipeline +├── tools/dev/ # doctor: сверка среды разработки с versions.env ├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria ├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения ├── docs/ # спецификации baseline, тестов и эксплуатации diff --git a/apps/controller/peer.go b/apps/controller/peer.go index d8f39cd..4dd05a6 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -93,9 +93,16 @@ func UpdatePeer(c *gin.Context) { return } peerUpdateDto.Id = &id - if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) { - vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) - return + if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" { + taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id) + if nameErr != nil { + vo.Fail(nameErr.Error(), c) + return + } + if taken { + vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) + return + } } if err = service.UpdatePeer(id, peerUpdateDto); err != nil { vo.Fail(err.Error(), c) diff --git a/apps/dao/admin_user.go b/apps/dao/admin_user.go index 853deaa..c78acc7 100644 --- a/apps/dao/admin_user.go +++ b/apps/dao/admin_user.go @@ -4,7 +4,6 @@ import ( "errors" "github.com/sirupsen/logrus" "gorm.io/gorm" - "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" "time" ) @@ -12,11 +11,11 @@ import ( func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) { var admin entity.AdminUser if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil { - if tx.Error == gorm.ErrRecordNotFound { - return admin, errors.New(constant.WrongPassword) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return admin, ErrAdminUserNotFound } logrus.Errorf("%v", tx.Error) - return admin, errors.New(constant.SysError) + return admin, ErrStorage } return admin, nil } @@ -24,7 +23,7 @@ func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, err func SaveAdminUser(admin entity.AdminUser) (int64, error) { if tx := sqliteDB.Save(&admin); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return 0, errors.New(constant.SysError) + return 0, ErrStorage } return *admin.Id, nil } @@ -36,7 +35,7 @@ func UpdateAdminUser(ids []int64, updates map[string]interface{}) error { updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } diff --git a/apps/dao/bootstrap_peer_test.go b/apps/dao/bootstrap_peer_test.go new file mode 100644 index 0000000..fc2652b --- /dev/null +++ b/apps/dao/bootstrap_peer_test.go @@ -0,0 +1,330 @@ +package dao + +import ( + "path/filepath" + "strings" + "testing" + + "hy2xs-admin/model/constant" + "hy2xs-admin/model/entity" +) + +const ( + testAdminPassword = "bootstrap-admin-password" + testBootstrapSecret = "bootstrap-peer-secret-value" + testTrafficStatsToken = "traffic-stats-machine-token" +) + +// startService воспроизводит запуск сервиса на указанной базе. +// +// Именно последовательность целиком, а не отдельная функция: дефект, ради +// которого написаны эти тесты, проявлялся только на ВТОРОМ запуске. +func startService(t *testing.T, dbPath string) error { + t.Helper() + + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword) + t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret) + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken) + + err := InitSqlAt(dbPath) + t.Cleanup(func() { _ = CloseSqliteDB() }) + return err +} + +func newInstalledService(t *testing.T) string { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := startService(t, dbPath); err != nil { + t.Fatalf("первый запуск сервиса не удался: %v", err) + } + return dbPath +} + +func testPeer(name string, authID string, digest string) entity.Peer { + remark := "" + cipher := "" + quota := int64(-1) + zero := int64(0) + maxDevices := int64(3) + + return entity.Peer{ + Name: &name, + Remark: &remark, + AuthId: &authID, + SecretDigest: &digest, + SecretEncrypted: &cipher, + QuotaBytes: "a, + DownloadBytes: &zero, + UploadBytes: &zero, + ExpiresAt: &zero, + MaxDevices: &maxDevices, + Disabled: &zero, + BannedUntil: &zero, + LastConnectionAt: &zero, + } +} + +func bootstrapPeerExists(t *testing.T) bool { + t.Helper() + + _, err := GetPeer("name = ?", BootstrapPeerName) + if err == nil { + return true + } + if !IsNotFound(err) { + t.Fatalf("неожиданная ошибка при чтении пира установщика: %v", err) + } + return false +} + +// Установка создаёт пира установщика и отмечает это в базе. +func TestBootstrapPeerIsSeededOnFirstStart(t *testing.T) { + newInstalledService(t) + + if !bootstrapPeerExists(t) { + t.Fatal("пир установщика не создан при первом запуске") + } + + seeded, err := bootstrapPeerSeeded() + if err != nil { + t.Fatalf("чтение отметки: %v", err) + } + if !seeded { + t.Fatal("отметка о создании пира установщика не выставлена") + } +} + +// Обычный перезапуск сервиса ничего не меняет: пир на месте, дублей нет. +func TestBootstrapPeerSurvivesRestartUntouched(t *testing.T) { + dbPath := newInstalledService(t) + before, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика не найден после установки: %v", err) + } + _ = CloseSqliteDB() + + if err := startService(t, dbPath); err != nil { + t.Fatalf("перезапуск сервиса не удался: %v", err) + } + + after, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика пропал после перезапуска: %v", err) + } + if *after.Id != *before.Id { + t.Fatalf("пир установщика пересоздан: id %d -> %d", *before.Id, *after.Id) + } + + peers, err := ListPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("список пиров: %v", err) + } + if len(peers) != 1 { + t.Fatalf("после перезапуска пиров установщика %d, ожидался ровно один", len(peers)) + } +} + +// Главный инвариант: удаление пира установщика — необратимый отзыв доступа. +// +// Раньше HY2XS_ADMIN_CON_PASS оставался в /etc/hy2xs/hy2xs.env, и признаком +// «создавать или нет» служило наличие строки в таблице. Поэтому +// `systemctl restart hy2xs-admin` после удаления возвращал того же пира с тем +// же секретом, молча, и отозванные учётные данные снова становились +// действующими. +func TestDeletedBootstrapPeerIsNotRecreatedOnRestart(t *testing.T) { + dbPath := newInstalledService(t) + + peer, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика не найден после установки: %v", err) + } + if err := DeletePeer([]int64{*peer.Id}); err != nil { + t.Fatalf("удаление пира установщика отклонено: %v", err) + } + _ = CloseSqliteDB() + + // Переменная окружения на месте — ровно то состояние, в котором сервис и + // живёт после установки. + if err := startService(t, dbPath); err != nil { + t.Fatalf("перезапуск сервиса после отзыва не удался: %v", err) + } + + if bootstrapPeerExists(t) { + t.Fatal("пир установщика воскрес после перезапуска: отзыв доступа не переживает restart") + } +} + +// Отзыв обязан пережить не один перезапуск, а любое их число. +func TestDeletedBootstrapPeerStaysGoneAcrossRepeatedRestarts(t *testing.T) { + dbPath := newInstalledService(t) + + peer, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика не найден: %v", err) + } + if err := DeletePeer([]int64{*peer.Id}); err != nil { + t.Fatalf("удаление: %v", err) + } + _ = CloseSqliteDB() + + for i := 0; i < 3; i++ { + if err := startService(t, dbPath); err != nil { + t.Fatalf("перезапуск #%d не удался: %v", i+1, err) + } + if bootstrapPeerExists(t) { + t.Fatalf("пир установщика воскрес на перезапуске #%d", i+1) + } + _ = CloseSqliteDB() + } +} + +// Отключённый пир остаётся в базе и не переподписывается сам собой. +func TestDisabledBootstrapPeerKeepsItsStateAcrossRestart(t *testing.T) { + dbPath := newInstalledService(t) + + peer, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика не найден: %v", err) + } + if err := UpdatePeer([]int64{*peer.Id}, map[string]interface{}{"disabled": 1}); err != nil { + t.Fatalf("отключение пира установщика: %v", err) + } + _ = CloseSqliteDB() + + if err := startService(t, dbPath); err != nil { + t.Fatalf("перезапуск: %v", err) + } + + after, err := GetPeer("name = ?", BootstrapPeerName) + if err != nil { + t.Fatalf("пир установщика пропал: %v", err) + } + if after.Disabled == nil || *after.Disabled != 1 { + t.Fatal("перезапуск снял отключение с пира установщика") + } + if *after.SecretDigest != *peer.SecretDigest { + t.Fatal("перезапуск переподписал пира установщика") + } +} + +// Отсутствие HY2XS_ADMIN_CON_PASS на чистой базе — отказ старта, а не повод +// придумать секрет: он продублирован в /etc/hy2xs/bootstrap-admin.secret. +func TestFirstStartWithoutBootstrapSecretFails(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword) + t.Setenv("HY2XS_ADMIN_CON_PASS", "") + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken) + + err := InitSqlAt(dbPath) + t.Cleanup(func() { _ = CloseSqliteDB() }) + + if err == nil { + t.Fatal("запуск без HY2XS_ADMIN_CON_PASS не отклонён") + } + if !strings.Contains(err.Error(), "HY2XS_ADMIN_CON_PASS") { + t.Errorf("отказ не называет переменную: %v", err) + } +} + +// Перезапуск уже установленного сервиса без переменной обязан работать: пир +// создан однажды, и требовать секрет второй раз незачем. +func TestRestartWithoutBootstrapSecretSucceeds(t *testing.T) { + dbPath := newInstalledService(t) + _ = CloseSqliteDB() + + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword) + t.Setenv("HY2XS_ADMIN_CON_PASS", "") + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken) + + if err := InitSqlAt(dbPath); err != nil { + t.Fatalf("перезапуск установленного сервиса без HY2XS_ADMIN_CON_PASS отклонён: %v", err) + } + t.Cleanup(func() { _ = CloseSqliteDB() }) + + if !bootstrapPeerExists(t) { + t.Fatal("пир установщика пропал при перезапуске без переменной") + } +} + +// Отметка и сам пир пишутся неделимо: иначе падение между двумя операциями +// вернуло бы прежнее поведение на следующем старте. +func TestBootstrapSeedIsAtomic(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := InitSqliteDBAt(dbPath); err != nil { + t.Fatalf("открытие базы: %v", err) + } + t.Cleanup(func() { _ = CloseSqliteDB() }) + if err := RunMigrations(); err != nil { + t.Fatalf("миграции: %v", err) + } + + // Занимаем имя обычной записью: следующая вставка упрётся в UNIQUE(name) + // уже ВНУТРИ транзакции. Отметка не имеет права остаться выставленной. + if _, err := SavePeer(testPeer(BootstrapPeerName, "some-auth-id", "some-digest")); err != nil { + t.Fatalf("подготовка конфликтующей записи: %v", err) + } + + if err := seedBootstrapPeerOnce(testPeer(BootstrapPeerName, BootstrapPeerName, "bootstrap-digest")); err == nil { + t.Fatal("вставка с конфликтом UNIQUE(name) не отклонена") + } + + seeded, err := bootstrapPeerSeeded() + if err != nil { + t.Fatalf("чтение отметки: %v", err) + } + if seeded { + t.Fatal("отметка выставлена, хотя пир не записан: транзакции нет") + } +} + +// Токен trafficStats — межкомпонентный контракт: при пустом env и пустой базе +// админка обязана отказать, а не сгенерировать значение, которого не знает +// Hysteria. +func TestTrafficStatsSecretIsNotInvented(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword) + t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret) + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "") + + err := InitSqlAt(dbPath) + t.Cleanup(func() { _ = CloseSqliteDB() }) + + if err == nil { + t.Fatal("запуск без machine token не отклонён: админка придумала его сама") + } + if !strings.Contains(err.Error(), "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET") { + t.Errorf("отказ не называет переменную: %v", err) + } + + cfg, cfgErr := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + if cfgErr == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { + t.Fatal("в базе появился сгенерированный machine token") + } +} + +// Уже согласованный токен в базе позволяет запуститься без переменной: это не +// изобретение контракта, а использование существующего. +func TestTrafficStatsSecretFromDatabaseIsAccepted(t *testing.T) { + dbPath := newInstalledService(t) + _ = CloseSqliteDB() + + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword) + t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret) + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "") + + if err := InitSqlAt(dbPath); err != nil { + t.Fatalf("перезапуск с токеном в базе отклонён: %v", err) + } + t.Cleanup(func() { _ = CloseSqliteDB() }) + + cfg, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + if err != nil { + t.Fatalf("токен пропал из базы: %v", err) + } + if strings.TrimSpace(*cfg.Value) != testTrafficStatsToken { + t.Fatalf("токен подменён: %q", *cfg.Value) + } +} diff --git a/apps/dao/config.go b/apps/dao/config.go index 6438a0f..3800579 100644 --- a/apps/dao/config.go +++ b/apps/dao/config.go @@ -2,17 +2,23 @@ package dao import ( "errors" + "fmt" + "strings" + "time" + "github.com/sirupsen/logrus" "gorm.io/gorm" - "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" - "time" ) func SaveConfig(config entity.Config) (int64, error) { - if tx := sqliteDB.Save(&config); tx.Error != nil { + return saveConfigOn(sqliteDB, config) +} + +func saveConfigOn(db *gorm.DB, config entity.Config) (int64, error) { + if tx := db.Save(&config); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return 0, errors.New(constant.SysError) + return 0, ErrStorage } return *config.Id, nil } @@ -44,8 +50,15 @@ func UpsertConfigValue(key string, value string) error { if sqliteDB == nil { return errors.New("sqlite is not initialized") } + return upsertConfigValueOn(sqliteDB, key, value) +} - tx := sqliteDB.Model(&entity.Config{}). +// upsertConfigValueOn — та же запись, но по переданному соединению. +// +// Нужна для операций, которые обязаны быть неделимыми вместе с записью в другую +// таблицу: пир установщика и отметка о его создании (см. SeedBootstrapPeerOnce). +func upsertConfigValueOn(db *gorm.DB, key string, value string) error { + tx := db.Model(&entity.Config{}). Where("key = ?", key). Updates(map[string]interface{}{ "value": value, @@ -53,14 +66,14 @@ func UpsertConfigValue(key string, value string) error { }) if tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } if tx.RowsAffected > 0 { return nil } remark := key - if _, err := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil { + if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil { return err } return nil @@ -102,16 +115,43 @@ func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) er return updateConfigOn(c.db, keys, updates) } +// updateConfigOn обновляет существующие строки и отказывает, если обновлять +// было нечего. +// +// UPDATE без совпавших строк — не ошибка SQL: gorm возвращает nil и +// RowsAffected = 0. Пока результат определялся только по tx.Error, отсутствие +// строки выглядело успешной записью, и это не теоретическая аккуратность. +// Путь оператора выглядит так: +// +// controller.UpdateConfigs → service.UpdateConfigs (транзакция) +// → applyRuntimeConfigUpdates → RescheduleResetTraffic +// +// Применение к рантайму идёт ПОСЛЕ записи и полагается на её успех. При +// отсутствующей строке RESET_TRAFFIC_CRON планировщик получал новое расписание, +// API отвечал успехом, а база сохраняла старое значение — и ближайший +// перезапуск сервиса молча возвращал прежний cron. Оператор при этом дважды +// видел подтверждение изменения, которого не произошло. +// +// Здесь решение принимается по RowsAffected — ровно так же, как в +// UpsertConfigValue ниже, где эта же ошибка уже была разобрана и исправлена. +// Разница между двумя функциями одного файла и была настоящим дефектом. func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error { if len(updates) == 0 { return nil } updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") - if tx := db.Model(&entity.Config{}). + tx := db.Model(&entity.Config{}). Where("key in ?", keys). - Updates(updates); tx.Error != nil { + Updates(updates) + if tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage + } + if tx.RowsAffected == 0 { + return fmt.Errorf( + "config: не найдено ни одной строки для ключей %s; настройка не сохранена", + strings.Join(keys, ", "), + ) } return nil } @@ -120,11 +160,11 @@ func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) { var config entity.Config if tx := sqliteDB.Model(&entity.Config{}). Where(query, args...).First(&config); tx.Error != nil { - if tx.Error == gorm.ErrRecordNotFound { - return config, errors.New(constant.ConfigNotExist) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return config, ErrConfigNotFound } logrus.Errorf("%v", tx.Error) - return config, errors.New(constant.SysError) + return config, ErrStorage } return config, nil } @@ -134,7 +174,7 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) if tx := sqliteDB.Model(&entity.Config{}). Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return configs, errors.New(constant.SysError) + return configs, ErrStorage } return configs, nil } diff --git a/apps/dao/config_test.go b/apps/dao/config_test.go new file mode 100644 index 0000000..ae7cef9 --- /dev/null +++ b/apps/dao/config_test.go @@ -0,0 +1,145 @@ +package dao + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "hy2xs-admin/model/constant" +) + +func newConfigDB(t *testing.T) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := InitSqliteDBAt(dbPath); err != nil { + t.Fatalf("не удалось открыть тестовую базу: %v", err) + } + if err := RunMigrations(); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + t.Cleanup(func() { _ = CloseSqliteDB() }) +} + +// Запись в отсутствующую строку — отказ, а не тихий успех. +// +// UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и +// RowsAffected = 0. Пока результат определялся только по tx.Error, оператор +// получал подтверждение изменения, которого не произошло, а +// applyRuntimeConfigUpdates следом применял новое значение к планировщику — +// то есть рантайм и база расходились до ближайшего перезапуска сервиса. +func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) { + newConfigDB(t) + + if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil { + t.Fatalf("подготовка повреждённой базы: %v", tx.Error) + } + + err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}) + if err == nil { + t.Fatal("запись в отсутствующую строку выдана за успех") + } + if !strings.Contains(err.Error(), constant.ResetTrafficCron) { + t.Errorf("отказ не называет ключ: %v", err) + } + + // UpdateConfig обновляет существующее и не имеет права заводить ключи: + // иначе он молча создавал бы строки, которых нет в модели продукта. + if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) { + t.Fatalf("строка появилась после отказа: %v", err) + } +} + +// Обычная запись в существующую строку продолжает работать. +func TestUpdateConfigWritesExistingRow(t *testing.T) { + newConfigDB(t) + + if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil { + t.Fatalf("запись существующего ключа отклонена: %v", err) + } + + cfg, err := GetConfig("key = ?", constant.ResetTrafficCron) + if err != nil { + t.Fatalf("чтение: %v", err) + } + if *cfg.Value != "@weekly" { + t.Fatalf("значение не сохранено: %q", *cfg.Value) + } +} + +// Транзакционная запись обязана вести себя так же: отказ на отсутствующей +// строке откатывает всю партию, а не оставляет её наполовину применённой. +func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) { + newConfigDB(t) + + if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil { + t.Fatalf("подготовка: %v", tx.Error) + } + + err := WithConfigTx(func(tx ConfigTx) error { + if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil { + return err + } + return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"}) + }) + if err == nil { + t.Fatal("партия с отсутствующей строкой не отклонена") + } + + cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron) + if getErr != nil { + t.Fatalf("чтение: %v", getErr) + } + if *cfg.Value == "@weekly" { + t.Fatal("первый ключ партии остался применённым после отказа на втором") + } +} + +// Отсутствие записи и отказ хранилища — разные ответы. +func TestGetConfigReportsNotFoundDistinctly(t *testing.T) { + newConfigDB(t) + + _, err := GetConfig("key = ?", "NO_SUCH_KEY") + if !errors.Is(err, ErrConfigNotFound) { + t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err) + } + if errors.Is(err, ErrStorage) { + t.Fatal("отсутствие ключа выдано за отказ хранилища") + } +} + +func TestGetPeerReportsNotFoundDistinctly(t *testing.T) { + newConfigDB(t) + + _, err := GetPeer("name = ?", "no-such-peer") + if !errors.Is(err, ErrPeerNotFound) { + t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err) + } + if !IsNotFound(err) { + t.Fatal("IsNotFound не распознаёт ErrPeerNotFound") + } + if IsNotFound(ErrStorage) { + t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи") + } +} + +// UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа. +func TestUpsertConfigValueCreatesMissingRow(t *testing.T) { + newConfigDB(t) + + if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil { + t.Fatalf("подготовка: %v", tx.Error) + } + if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil { + t.Fatalf("upsert отклонён: %v", err) + } + + cfg, err := GetConfig("key = ?", constant.ResetTrafficCron) + if err != nil { + t.Fatalf("строка не создана: %v", err) + } + if *cfg.Value != "@daily" { + t.Fatalf("значение не сохранено: %q", *cfg.Value) + } +} diff --git a/apps/dao/dashboard.go b/apps/dao/dashboard.go index cd729e9..466f13f 100644 --- a/apps/dao/dashboard.go +++ b/apps/dao/dashboard.go @@ -1,9 +1,7 @@ package dao import ( - "errors" "github.com/sirupsen/logrus" - "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "strings" @@ -13,7 +11,7 @@ import ( func SaveMetricSample(sample entity.MetricSample) error { if tx := sqliteDB.Save(&sample); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -22,7 +20,7 @@ func LastMetricSample() (entity.MetricSample, error) { var sample entity.MetricSample if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return sample, errors.New(constant.SysError) + return sample, ErrStorage } return sample, nil } @@ -33,7 +31,7 @@ func CleanupMetricSample(olderThanMs int64) error { } if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -54,7 +52,7 @@ func DashboardPeerSummary(nowMs int64) (vo.DashboardPeerVo, error) { COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired FROM peer`, nowMs).Scan(&r); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return result, errors.New(constant.SysError) + return result, ErrStorage } result.Total = r.Total result.Enabled = r.Enabled @@ -75,7 +73,7 @@ func DashboardTrafficSummary() (vo.DashboardTrafficVo, error) { COALESCE(SUM(upload_bytes),0) AS upload FROM peer`).Scan(&r); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return result, errors.New(constant.SysError) + return result, ErrStorage } result.DownloadBytes = r.Download result.UploadBytes = r.Upload @@ -125,7 +123,7 @@ func DashboardTopPeers(fromMs int64, toMs int64, limit int, source string) ([]vo ORDER BY total DESC LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return rows, errors.New(constant.SysError) + return rows, ErrStorage } return rows, nil } @@ -164,7 +162,7 @@ func DashboardTrafficTimeseries(fromMs int64, toMs int64, bucketMs int64, source GROUP BY ts ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, queryFrom, toMs).Scan(&rows); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return rows, errors.New(constant.SysError) + return rows, ErrStorage } rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows)) @@ -203,7 +201,7 @@ func DashboardSystemTimeseries(fromMs int64, toMs int64, bucketMs int64) ([]vo.D GROUP BY ts ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return rows, errors.New(constant.SysError) + return rows, ErrStorage } return rows, nil } diff --git a/apps/dao/errors.go b/apps/dao/errors.go new file mode 100644 index 0000000..6f316bc --- /dev/null +++ b/apps/dao/errors.go @@ -0,0 +1,56 @@ +package dao + +import ( + "errors" + + "hy2xs-admin/model/constant" +) + +// Отличать «строки нет» от «база не ответила» обязан слой данных. +// +// Что было. Каждый Get* возвращал `errors.New(constant.WrongPassword)` для +// gorm.ErrRecordNotFound и `errors.New(constant.SysError)` для всего +// остального. Обе — свежие значения ошибки со строкой внутри, поэтому +// вызывающий мог отличить одно от другого только сравнением текста, чего никто +// не делал: везде проверялось `err != nil` или `err == nil`. +// +// Из-за этого сбой SQLite неотличим от отсутствия записи, а решения на этом +// принимаются серьёзные: +// +// ensureSecureBootstrapPeer — «пира нет» означает «создать пир заново»; +// applyPeerImportEntry — «по auth_id не нашли» означает «искать по имени» +// и, не найдя, СОЗДАТЬ запись; +// ExistPeerName — «ошибка» означает «имя свободно». +// +// То есть при недоступной базе продукт не отказывал, а трактовал отказ как +// разрешение действовать. Транзакция спасает целостность, но не смысл: решение +// уже принято неверно к моменту записи. +// +// Значения объявлены один раз и возвращаются как есть, поэтому работает +// errors.Is. Тексты сохранены прежними: WrongPassword уезжает в ответ Hysteria +// при неудачной machine-auth, и менять его здесь было бы изменением внешнего +// контракта ради внутренней аккуратности. +var ( + // ErrPeerNotFound — пира с такими условиями в базе нет. + ErrPeerNotFound = errors.New(constant.WrongPassword) + + // ErrAdminUserNotFound — учётной записи администратора нет. + ErrAdminUserNotFound = errors.New(constant.WrongPassword) + + // ErrConfigNotFound — строки с таким ключом в таблице `config` нет. + ErrConfigNotFound = errors.New(constant.ConfigNotExist) + + // ErrStorage — база не смогла ответить. Не «нет данных», а «неизвестно». + ErrStorage = errors.New(constant.SysError) +) + +// IsNotFound — истина только для «записи нет», и никогда для отказа хранилища. +// +// Помощник существует, чтобы у вызывающих был ровно один способ задать этот +// вопрос: три разных sentinel'а для трёх таблиц иначе неизбежно проверялись бы +// по-разному в разных местах. +func IsNotFound(err error) bool { + return errors.Is(err, ErrPeerNotFound) || + errors.Is(err, ErrAdminUserNotFound) || + errors.Is(err, ErrConfigNotFound) +} diff --git a/apps/dao/peer.go b/apps/dao/peer.go index d6b0622..d57a43d 100644 --- a/apps/dao/peer.go +++ b/apps/dao/peer.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/sirupsen/logrus" "gorm.io/gorm" - "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "time" @@ -50,7 +49,7 @@ func WithPeerTx(fn func(tx PeerTx) error) error { func (p peerTx) SavePeer(peer entity.Peer) (int64, error) { if tx := p.db.Save(&peer); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return 0, errors.New(constant.SysError) + return 0, ErrStorage } return *peer.Id, nil } @@ -62,19 +61,24 @@ func (p peerTx) UpdatePeer(ids []int64, updates map[string]interface{}) error { updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } +// GetPeer различает «пира нет» и «база не ответила» разными значениями ошибки. +// +// Раньше оба случая возвращались как свежие errors.New, и вызывающий не имел +// способа их разделить иначе как сравнением текста. Разница принципиальна: +// «нет» разрешает создать запись, «неизвестно» — не разрешает ничего. func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) { var peer entity.Peer if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil { - if tx.Error == gorm.ErrRecordNotFound { - return peer, errors.New(constant.WrongPassword) + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return peer, ErrPeerNotFound } logrus.Errorf("%v", tx.Error) - return peer, errors.New(constant.SysError) + return peer, ErrStorage } return peer, nil } @@ -99,7 +103,7 @@ func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) { func DeletePeer(ids []int64) error { if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -108,7 +112,7 @@ func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) { var peers []entity.Peer if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return peers, errors.New(constant.SysError) + return peers, ErrStorage } return peers, nil } @@ -129,7 +133,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) { tx.Count(&total) if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return peers, 0, errors.New(constant.SysError) + return peers, 0, ErrStorage } return peers, total, nil } diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 6fb0883..66eb0d0 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -59,7 +59,19 @@ func InitSqliteDBAt(path string) error { // порт, который получил аргументом из ExecStart, а startup тут же читал его // обратно. База не была источником этого факта ни на одном шаге. func InitSql() error { - if err := InitSqliteDB(); err != nil { + return InitSqlAt(constant.SqliteDBPath) +} + +// InitSqlAt поднимает базу по указанному пути. +// +// Путь параметризован по той же причине, что и у InitSqliteDBAt: жизненный +// цикл пира установщика — единственное, что доказывает необратимость отзыва +// доступа, и проверить его можно только прогнав ПОЛНУЮ последовательность +// запуска (миграции → seeder → повторный запуск) дважды на одной базе. +// Отдельная проверка ensureSecureBootstrapPeer в отрыве от InitSql доказывала +// бы поведение функции, а не поведение сервиса при перезапуске. +func InitSqlAt(path string) error { + if err := InitSqliteDBAt(path); err != nil { return err } if err := runMigrations(); err != nil { @@ -156,30 +168,78 @@ func ensureSecureBootstrapAdmin() error { return nil } -// ensureSecureBootstrapPeer создаёт пир установщика. +// BootstrapPeerName — имя и authId пира, который создаёт установка. // -// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и -// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё -// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора -// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с -// файлом, и первая же проверка подключения после установки провалилась бы — -// на корректном во всём остальном сервере. +// Объявлено здесь, а не литералом в четырёх местах: то же значение проверяет +// service.ReservedBootstrapPeerName, и расхождение между ними означало бы, что +// guard защищает не тот пир, который создал seeder. +const BootstrapPeerName = "bootstrap-admin-peer" + +// bootstrapPeerSeededValue — значение маркера constant.BootstrapPeerSeeded. +const bootstrapPeerSeededValue = "true" + +// ensureSecureBootstrapPeer создаёт пир установщика РОВНО ОДИН РАЗ за жизнь +// базы. // -// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать -// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса -// без переменной падал бы на ровном месте. +// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS: он лежит ещё и в +// /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора для +// проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с файлом, и +// первая же проверка подключения после установки провалилась бы — на +// корректном во всём остальном сервере. +// +// Что было и почему это была дыра в отзыве доступа. +// +// Признаком «создавать или нет» служило наличие строки в таблице пиров. Панель +// при этом разрешает удалить bootstrap-admin-peer, и это осознанное решение: +// после установки он остаётся обычным действующим доступом, а запрет удаления +// означал бы вечный неотзываемый вход. Но HY2XS_ADMIN_CON_PASS продолжает жить +// в /etc/hy2xs/hy2xs.env, который читает systemd-юнит, поэтому: +// +// оператор удаляет bootstrap-admin-peer → доступ действительно исчезает +// systemctl restart hy2xs-admin / reboot → InitSql → ensureSecureBootstrapPeer +// → строки нет → создать заново +// → ТОТ ЖЕ секрет снова действует +// +// То есть отзыв учётных данных не переживал перезапуск, и восстановление +// происходило молча: в журнале ничего, в списке пиров запись просто снова есть. +// Отзыв, который не переживает restart, отзывом не является. +// +// Теперь решение принимается по отдельной отметке в таблице `config`. Она +// отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас», +// поэтому удаление становится окончательным. Отметка и сам пир пишутся одной +// транзакцией: иначе падение между двумя операциями вернуло бы прежнее +// поведение на следующем старте. +// +// Порядок проверок сохранён: сначала выясняем, нужно ли вообще создавать пир, и +// только потом требуем env. Перезапуск установленного сервиса без переменной +// обязан работать штатно. func ensureSecureBootstrapPeer() error { - if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil { + seeded, err := bootstrapPeerSeeded() + if err != nil { + return err + } + if seeded { return nil } - if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil { - return nil + + // Пир есть, а отметки нет. На чистой установке это недостижимо: имя + // зарезервировано во всех путях записи панели, поэтому единственный + // создатель такой строки — этот же seeder. Ветка закрывает базу, созданную + // сборкой до появления отметки, и делает функцию идемпотентной, не + // возвращая при этом прежнюю семантику: она срабатывает только при + // отсутствующем маркере. + switch _, err := GetPeer("name = ?", BootstrapPeerName); { + case err == nil: + return UpsertConfigValue(constant.BootstrapPeerSeeded, bootstrapPeerSeededValue) + case !errors.Is(err, ErrPeerNotFound): + // База не ответила. «Неизвестно» не даёт права создавать учётные данные. + return err } bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS")) if bootstrapSecret == "" { return errors.New( - "HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" + + "HY2XS_ADMIN_CON_PASS не задан, а пир установщика bootstrap-admin-peer ещё ни разу не создавался.\n" + "Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" + "/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" + "Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" + @@ -194,8 +254,18 @@ func ensureSecureBootstrapPeer() error { return err } - if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil { - return nil + // Чужой пир с тем же секретом. Создавать нельзя — UNIQUE(secret_digest), — + // но и молчать нельзя: секрет установщика уже выдан кому-то ещё. + switch _, err := GetPeer("secret_digest = ?", secretDigest); { + case err == nil: + return errors.New( + "секрет из HY2XS_ADMIN_CON_PASS уже принадлежит другому пиру.\n" + + "Пир установщика не создан: два пира не могут делить один секрет.\n" + + "Переименуйте или удалите этот пир, либо смените HY2XS_ADMIN_CON_PASS\n" + + "через `hy2xs-orchestrator reconfigure`.", + ) + case !errors.Is(err, ErrPeerNotFound): + return err } secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret) @@ -203,8 +273,8 @@ func ensureSecureBootstrapPeer() error { return err } - name := "bootstrap-admin-peer" - authID := "bootstrap-admin-peer" + name := BootstrapPeerName + authID := BootstrapPeerName remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS" quota := int64(-1) download := int64(0) @@ -230,8 +300,44 @@ func ensureSecureBootstrapPeer() error { BannedUntil: &bannedUntil, LastConnectionAt: &lastConnection, } - _, saveErr := SavePeer(peer) - return saveErr + return seedBootstrapPeerOnce(peer) +} + +// bootstrapPeerSeeded читает отметку о том, что пир установщика уже создавался. +// +// Отсутствие строки — это «ещё не создавался», а не отказ: миграция 000 +// заводит ключ пустым, но база могла быть создана и до его появления. Любая +// ДРУГАЯ ошибка — отказ хранилища, и трактовать её как «не создавался» нельзя: +// именно так и появлялся бы пир заново при недоступной базе. +func bootstrapPeerSeeded() (bool, error) { + cfg, err := GetConfig("key = ?", constant.BootstrapPeerSeeded) + if err != nil { + if errors.Is(err, ErrConfigNotFound) { + return false, nil + } + return false, err + } + if cfg.Value == nil { + return false, nil + } + return strings.TrimSpace(*cfg.Value) == bootstrapPeerSeededValue, nil +} + +// seedBootstrapPeerOnce пишет пир и отметку о его создании неделимо. +// +// Раздельная запись вернула бы исходный дефект в новой форме: упади процесс +// между SavePeer и записью отметки, следующий старт снова увидел бы +// «не создавался» и — после удаления пира оператором — создал бы его заново. +func seedBootstrapPeerOnce(peer entity.Peer) error { + if sqliteDB == nil { + return errors.New("sqlite is not initialized") + } + return sqliteDB.Transaction(func(tx *gorm.DB) error { + if _, err := (peerTx{db: tx}).SavePeer(peer); err != nil { + return err + } + return upsertConfigValueOn(tx, constant.BootstrapPeerSeeded, bootstrapPeerSeededValue) + }) } func buildPeerSecretDigest(rawSecret string) (string, error) { @@ -370,12 +476,18 @@ func seedBaseConfig() error { // // Оставшееся — четыре внутренних секрета и единственная настройка, которой // действительно владеет панель. + // + // BOOTSTRAP_PEER_SEEDED заводится пустым: пустое значение означает «пир + // установщика ещё ни разу не создавался», и это верное состояние для + // чистой базы. Отметку выставляет ensureSecureBootstrapPeer той же + // транзакцией, которой создаёт пир. defaults := map[string]string{ constant.JwtSecret: "", constant.ResetTrafficCron: "0 0 * * *", constant.Hysteria2TrafficStatsSecret: "", constant.PeerSecretKey: "", constant.PeerSecretEncryptionKey: "", + constant.BootstrapPeerSeeded: "", } for k, v := range defaults { if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil { @@ -597,33 +709,64 @@ func tableExists(tableName string) bool { } return count > 0 } -// ensureTrafficStatsSecret синхронизирует machine token с окружением. +// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ +// придумывает его сам. // -// Владелец значения — оркестратор: он же кладёт этот секрет в +// Владелец значения — оркестратор. Он же кладёт этот секрет в // /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к -// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а -// собственная генерация — только для случая, когда переменной нет вовсе. +// /internal/hysteria/auth, и в /etc/hy2xs/hy2xs.env, откуда его читает +// systemd-юнит админки. Токен — межкомпонентный контракт, а не внутреннее +// состояние панели. // -// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue: -// каждая из них по-своему решала «есть строка или нет», и две из четырёх -// решали неверно (см. комментарий к UpsertConfigValue). +// Что было. При пустом env и пустой базе админка генерировала случайный токен +// сама. Записать его в /etc/hysteria/config.yaml она не может — файл +// принадлежит оркестратору и для пользователя hy2xs-admin доступен только на +// чтение (это проверяет smoke). Результат: +// +// admin стартовал успешно +// Hysteria продолжала слать старый токен +// machine auth переставал совпадать +// +// То есть повреждённый контракт запуска не диагностировался, а маскировался +// «починкой» неправильным способом: сервис объявлял себя здоровым, а +// авторизация клиентов молча ломалась. +// +// Тот же принцип, что уже применён к HY2XS_ADMIN_INITIAL_PASSWORD и +// HY2XS_ADMIN_CON_PASS: значение, которого не знает никто, кроме сгенерировавшего +// его процесса, бесполезно, и правильная реакция на его отсутствие громкая. +// +// Три допустимых состояния: +// +// env задан → синхронизировать базу с ним (env главнее); +// env пуст, в базе токен есть → рабочее состояние, ничего не делаем; +// env пуст, в базе пусто → отказ старта. +// +// Вторая ветка нужна для ручного `systemctl start` без EnvironmentFile: она не +// изобретает контракт, а использует уже согласованный. func ensureTrafficStatsSecret() error { envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET")) if envSecret != "" { return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret) } - if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil { - if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { - return nil - } + existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + if err != nil && !errors.Is(err, ErrConfigNotFound) { + return err + } + if err == nil && existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { + return nil } - secret, secErr := util.RandomString(32) - if secErr != nil { - return secErr - } - return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret) + return errors.New( + "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET не задан, и в базе этого токена тоже нет.\n" + + "Это межкомпонентный контракт, а не настройка панели: тем же значением\n" + + "Hysteria обращается к /internal/hysteria/auth, и владеет им\n" + + "install-оркестратор через /etc/hysteria/config.yaml и /etc/hy2xs/hy2xs.env.\n" + + "Сгенерированный админкой токен не попал бы в конфиг Hysteria: этот файл\n" + + "доступен ей только на чтение, — и machine auth перестал бы совпадать.\n" + + "Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" + + "`hy2xs-orchestrator repair --allow-partial-state`.", + ) } func CloseSqliteDB() error { diff --git a/apps/dao/traffic.go b/apps/dao/traffic.go index f1e9202..a728bda 100644 --- a/apps/dao/traffic.go +++ b/apps/dao/traffic.go @@ -1,19 +1,17 @@ package dao import ( - "errors" "fmt" "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" - "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" ) func SaveTrafficSample(sample entity.TrafficSample) error { if tx := sqliteDB.Save(&sample); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -36,7 +34,7 @@ func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64, }), }).Create(&agg); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -59,7 +57,7 @@ func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, tx }), }).Create(&agg); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -74,7 +72,7 @@ func CleanupTrafficSample(olderThanMs int64) error { } if tx := sqliteDB.Exec("DELETE FROM traffic_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -85,7 +83,7 @@ func CleanupTrafficAggregateHourly(olderThanMs int64) error { } if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_hourly WHERE hour_start < ?", olderThanMs); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } @@ -96,7 +94,7 @@ func CleanupTrafficAggregateDaily(olderThanMs int64) error { } if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_daily WHERE day_start < ?", olderThanMs); tx.Error != nil { logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) + return ErrStorage } return nil } diff --git a/apps/go.mod b/apps/go.mod index 00bc41f..eca7e9a 100644 --- a/apps/go.mod +++ b/apps/go.mod @@ -6,6 +6,19 @@ module hy2xs-admin // с ней в бинарь попадала соответствующая stdlib. go 1.25.0 +// toolchain объявляет, каким компилятором проект собирается на самом деле. +// +// Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает +// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя +// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть +// разработчик и сборка проверяли разный код, а расхождение не было видно ни в +// одном из выводов. +// +// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет +// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же +// расхождение локально, до сборки. +toolchain go1.26.7 + require ( github.com/didip/tollbooth v4.0.2+incompatible github.com/gin-gonic/gin v1.12.0 diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go index 8df79b9..e6a4b59 100644 --- a/apps/model/constant/config.go +++ b/apps/model/constant/config.go @@ -30,6 +30,21 @@ const ( PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET" ResetTrafficCron = "RESET_TRAFFIC_CRON" + + // BootstrapPeerSeeded — отметка о том, что пир установщика УЖЕ был создан + // однажды. Не «существует сейчас», а именно «был создан». + // + // Различие и есть суть ключа. Раньше единственным признаком служило наличие + // строки в таблице пиров, поэтому удаление bootstrap-admin-peer выглядело + // отзывом доступа, но им не было: HY2XS_ADMIN_CON_PASS продолжает жить в + // /etc/hy2xs/hy2xs.env, и ближайший `systemctl restart hy2xs-admin` или + // перезагрузка сервера возвращали тот же самый секрет в базу. Молча. + // + // Отзыв учётных данных, который не переживает перезапуск, — не отзыв. + // Маркер делает удаление окончательным: seeder читает его, а не таблицу + // пиров, поэтому после первого успешного bootstrap он не создаёт запись + // больше никогда. + BootstrapPeerSeeded = "BOOTSTRAP_PEER_SEEDED" ) // Что операторский API имеет право прочитать и записать в таблице `config`. diff --git a/apps/router/peer.go b/apps/router/peer.go index ffa8c42..ee8c261 100644 --- a/apps/router/peer.go +++ b/apps/router/peer.go @@ -19,9 +19,16 @@ func initPeerRouter(peerApi *gin.RouterGroup) { peers.PATCH("/:id/kick", controller.KickPeer) peers.GET("/:id/client-config", controller.PeerClientConfig) - // aliases на один миграционный релиз - peers.GET("/:id/client-url", controller.PeerClientConfig) - peers.GET("/:id/qr", controller.PeerClientConfig) + // Алиасов /:id/client-url и /:id/qr здесь больше нет. + // + // Они были оставлены «на один миграционный релиз», но миграционного + // релиза у продукта не существует: HY2XS v1 ставится только на чистый + // хост, база предыдущего поколения не переносится, а клиентов у этих + // путей нет — панель ходит в /:id/client-config. Дожив до 1.0.0, они + // стали бы частью публичного API v1, и убирать их пришлось бы уже со + // сломом совместимости. + // + // Отсутствие закреплено TestRouterHasNoRemovedRoutes. } peerApi.POST("/peer-import", controller.ImportPeer) diff --git a/apps/router/router_test.go b/apps/router/router_test.go index 8e478d8..abd8f36 100644 --- a/apps/router/router_test.go +++ b/apps/router/router_test.go @@ -88,6 +88,11 @@ func TestRouterHasNoRemovedRoutes(t *testing.T) { // входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY. // Потребителей у него не было ни одного. "getConfig", + // Алиасы клиентской конфигурации, оставленные «на один миграционный + // релиз». Миграционного релиза у clean-install-only продукта нет, а в + // 1.0.0 они стали бы частью публичного API. + "client-url", + "/qr", } for _, route := range buildRoutes(t) { for _, dead := range removed { diff --git a/apps/service/peer.go b/apps/service/peer.go index 7b06b54..9560a18 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -80,8 +80,12 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) } - if ExistPeerName(*peerDto.Name, 0) { - return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name)) + taken, err := ExistPeerName(*peerDto.Name, 0) + if err != nil { + return vo.PeerVo{}, err + } + if taken { + return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name) } secret := "" if peerDto.Secret != nil && *peerDto.Secret != "" { @@ -203,6 +207,18 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e // обязан иметь возможность его отозвать. В отличие от смены секрета, удаление // не создаёт расхождения между базой и файлом: пира просто нет, и это видно в // списке. +// +// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а +// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока +// признаком «создавать или нет» служило наличие строки в таблице, удаление +// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не +// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом +// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе +// панель предлагает операции, которых не выполняет. +// +// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом +// владеет оркестратор, админка его не трогает; после отзыва он содержит уже +// недействующее значение (см. docs/04-admin-panel.md). func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } func GetPeerVo(id int64) (vo.PeerVo, error) { @@ -248,10 +264,42 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) { return vo.PeerClientConfigVo{Url: url}, nil } +// ListExportPeer собирает выгрузку пиров в одном из двух режимов. +// +// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе, +// поле secret остаётся пустым, и это ожидаемое содержимое файла. +// +// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными, +// которую оператор запрашивает отдельной кнопкой с подтверждением риска. +// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет. +// +// Что было: +// +// if includeSecrets && item.SecretEncrypted != nil { +// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil { +// ex.Secret = dec +// } +// } +// +// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир +// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом +// завершался успехом, и оператор получал файл, выглядящий полным: +// +// [{"name":"A","secret":"..."}, +// {"name":"B","secret":""}, <- потерян молча +// {"name":"C","secret":"..."}] +// +// Обнаруживалось это уже после импорта на новом сервере: B получал новый +// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в +// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»; +// частичный результат под этим именем — худший из возможных ответов. +// +// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а +// без имени оператору некуда идти с этой ошибкой. func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { peers, err := dao.ListPeer("1=1") if err != nil { - return nil, errors.New(constant.SysError) + return nil, err } out := make([]bo.PeerExport, 0, len(peers)) for _, item := range peers { @@ -269,16 +317,47 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { BannedUntil: int64Val(item.BannedUntil), LastConnectionAt: int64Val(item.LastConnectionAt), } - if includeSecrets && item.SecretEncrypted != nil { - if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil { - ex.Secret = dec + if includeSecrets { + secret, secretErr := exportPeerSecret(item) + if secretErr != nil { + return nil, secretErr } + ex.Secret = secret } out = append(out, ex) } return out, nil } +// exportPeerSecret возвращает действующий секрет пира или объясняет, почему +// резервная копия невозможна. +func exportPeerSecret(peer entity.Peer) (string, error) { + name := strVal(peer.Name) + if name == "" { + name = fmt.Sprintf("id=%d", int64Val(peer.Id)) + } + + if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" { + return "", fmt.Errorf( + "резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+ + "Файл не сформирован целиком, чтобы копия не выглядела полной. "+ + "Пересоздайте секрет этого пира или выгрузите список без учётных данных", + name, + ) + } + + decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted) + if err != nil { + return "", fmt.Errorf( + "резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+ + "Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+ + "Файл не сформирован целиком, чтобы копия не выглядела полной", + name, + ) + } + return decrypted, nil +} + // preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным // заранее. // @@ -416,26 +495,21 @@ func UpsertPeerExport(items []bo.PeerExport) error { } func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error { - var existing entity.Peer - var err error - - if entry.authID != "" { - existing, err = tx.GetPeer("auth_id = ?", entry.authID) - } - if err != nil || existing.Id == nil { - existing, err = tx.GetPeer("name = ?", entry.name) + existing, found, err := findPeerForImport(tx, entry) + if err != nil { + return err } // Пир установщика не переопределяется импортом ни при каком совпадении: // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. - if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { + if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { return fmt.Errorf( "peer import: пир %q принадлежит установщику и не может быть изменён импортом", ReservedBootstrapPeerName, ) } - if err == nil && existing.Id != nil { + if found { updates := map[string]interface{}{ "name": entry.name, "remark": entry.remark, @@ -491,14 +565,66 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error { return saveErr } -func ExistPeerName(name string, id int64) bool { +// findPeerForImport ищет запись, которую импорт должен обновить. +// +// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше +// последних двух не существовало — код выглядел так: +// +// existing, err = tx.GetPeer("auth_id = ?", entry.authID) +// if err != nil || existing.Id == nil { +// existing, err = tx.GetPeer("name = ?", entry.name) +// } +// +// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего +// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет +// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а +// пытался наплодить дубликаты; целостность спасала транзакция, но решение +// принималось на несуществующих данных. +// +// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной +// ключ для файлов, выгруженных без него. +func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) { + if entry.authID != "" { + peer, err := tx.GetPeer("auth_id = ?", entry.authID) + switch { + case err == nil: + return peer, true, nil + case !errors.Is(err, dao.ErrPeerNotFound): + return entity.Peer{}, false, err + } + } + + peer, err := tx.GetPeer("name = ?", entry.name) + switch { + case err == nil: + return peer, true, nil + case errors.Is(err, dao.ErrPeerNotFound): + return entity.Peer{}, false, nil + default: + return entity.Peer{}, false, err + } +} + +// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может. +// +// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища +// означал «имя свободно». Проверка уникальности, которая при недоступной базе +// отвечает «можно», ничего не проверяет. +func ExistPeerName(name string, id int64) (bool, error) { var err error if id != 0 { _, err = dao.GetPeer("name = ? and id != ?", name, id) } else { _, err = dao.GetPeer("name = ?", name) } - return err == nil + switch { + case err == nil: + return true, nil + case errors.Is(err, dao.ErrPeerNotFound): + return false, nil + default: + return false, err + } } func UpdatePeerLastConnectionAt(id int64, conAt int64) error { diff --git a/apps/service/peer_export_backup_test.go b/apps/service/peer_export_backup_test.go new file mode 100644 index 0000000..dcb52d1 --- /dev/null +++ b/apps/service/peer_export_backup_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "strings" + "testing" + + "hy2xs-admin/dao" + "hy2xs-admin/model/entity" +) + +// Резервная копия с учётными данными обязана быть полной или отсутствовать. +// +// Раньше ListExportPeer(true) молча пропускала пиров, чей секрет не удалось +// получить, и отдавала успешный ответ с пустым полем secret. Оператор, +// специально выбравший режим «копия с действующими credentials», узнавал о +// потере только после импорта на новом сервере — по отвалившимся клиентам. + +func seedPeerWithCipher(t *testing.T, name string, authID string, cipher string) int64 { + t.Helper() + + digest, err := PeerSecretDigest(name + "-secret") + if err != nil { + t.Fatalf("digest: %v", err) + } + + remark := "" + quota := int64(-1) + zero := int64(0) + maxDevices := int64(3) + + id, err := dao.SavePeer(entity.Peer{ + Name: &name, + Remark: &remark, + AuthId: &authID, + SecretDigest: &digest, + SecretEncrypted: &cipher, + QuotaBytes: "a, + DownloadBytes: &zero, + UploadBytes: &zero, + ExpiresAt: &zero, + MaxDevices: &maxDevices, + Disabled: &zero, + BannedUntil: &zero, + LastConnectionAt: &zero, + }) + if err != nil { + t.Fatalf("не удалось создать пира %s: %v", name, err) + } + return id +} + +func TestBackupExportReturnsEverySecret(t *testing.T) { + newTestDB(t) + seedPeer(t, "alpha1", "alpha-auth") + seedPeer(t, "bravo2", "bravo-auth") + + items, err := ListExportPeer(true) + if err != nil { + t.Fatalf("резервная копия отклонена на исправных данных: %v", err) + } + if len(items) != 2 { + t.Fatalf("ожидалось 2 записи, получено %d", len(items)) + } + for _, item := range items { + if item.Secret == "" { + t.Errorf("пир %s уехал в копию без секрета", item.Name) + } + } +} + +// Повреждённый шифртекст: расшифровка падает, и весь бэкап обязан упасть +// вместе с ней, назвав пира по имени. +func TestBackupExportFailsWhenAnySecretCannotBeDecrypted(t *testing.T) { + newTestDB(t) + seedPeer(t, "alpha1", "alpha-auth") + seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext") + seedPeer(t, "charl3", "charlie-auth") + + items, err := ListExportPeer(true) + if err == nil { + t.Fatal("резервная копия с нерасшифровываемым секретом не отклонена") + } + if items != nil { + t.Error("при отказе не должно возвращаться частичное содержимое файла") + } + if !strings.Contains(err.Error(), "bravo2") { + t.Errorf("отказ не называет проблемного пира: %v", err) + } +} + +// Пир вовсе без сохранённого шифртекста — второй молчаливый канал потери. +func TestBackupExportFailsWhenSecretIsMissing(t *testing.T) { + newTestDB(t) + seedPeer(t, "alpha1", "alpha-auth") + seedPeerWithCipher(t, "bravo2", "bravo-auth", "") + + _, err := ListExportPeer(true) + if err == nil { + t.Fatal("резервная копия для пира без секрета не отклонена") + } + if !strings.Contains(err.Error(), "bravo2") { + t.Errorf("отказ не называет проблемного пира: %v", err) + } +} + +// Безопасная выгрузка шифртекст не трогает вовсе: пустой secret там — не +// потеря, а весь смысл режима. Повреждённые данные не должны ей мешать. +func TestSafeExportIgnoresBrokenSecrets(t *testing.T) { + newTestDB(t) + seedPeer(t, "alpha1", "alpha-auth") + seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext") + + items, err := ListExportPeer(false) + if err != nil { + t.Fatalf("безопасная выгрузка отклонена: %v", err) + } + if len(items) != 2 { + t.Fatalf("ожидалось 2 записи, получено %d", len(items)) + } + for _, item := range items { + if item.Secret != "" { + t.Errorf("безопасная выгрузка содержит секрет пира %s", item.Name) + } + } +} diff --git a/apps/service/peer_import.go b/apps/service/peer_import.go index 9e32e51..7129249 100644 --- a/apps/service/peer_import.go +++ b/apps/service/peer_import.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "hy2xs-admin/dao" "hy2xs-admin/model/bo" ) @@ -30,7 +31,11 @@ const MaxPeerImportItems = 5000 // HY2XS_ADMIN_CON_PASS. Его секрет продублирован в // /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча // рассинхронизировала бы файл на диске с базой. -const ReservedBootstrapPeerName = "bootstrap-admin-peer" +// +// Значение берётся из dao, а не пишется здесь второй раз: guard обязан +// защищать ровно тот пир, который создаёт seeder, и две независимые строковые +// константы рано или поздно разошлись бы. +const ReservedBootstrapPeerName = dao.BootstrapPeerName // Тот же набор символов, что и у validateStr в слое контроллеров. var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`) diff --git a/apps/service/peer_secret.go b/apps/service/peer_secret.go index 124fa04..799ebd3 100644 --- a/apps/service/peer_secret.go +++ b/apps/service/peer_secret.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "strings" "hy2xs-admin/dao" @@ -42,9 +43,37 @@ func EncryptPeerSecret(rawSecret string) (string, error) { return util.EncryptAESGCM(rawSecret, key) } +// DecryptPeerSecret расшифровывает сохранённый секрет пира. +// +// Формат хранения ровно один: `v1:` + AES-GCM. Значение без этого префикса — +// не «секрет в старом формате», а повреждённые данные, и ответом на них +// является ошибка. +// +// Что было: +// +// if !strings.HasPrefix(stored, "v1:") { +// return stored, nil +// } +// +// то есть содержимое колонки возвращалось как якобы успешно расшифрованный +// секрет. Ветка досталась от поколения, в котором секреты пиров лежали в базе +// открытым текстом; при clean-install-only политике такой строки не может +// существовать — EncryptPeerSecret всегда пишет префикс, — а вред остаётся: +// +// повреждённая колонка → мусор уходит в клиентскую ссылку как секрет; +// резервная копия с секретами → мусор попадает в файл вместо credentials; +// значение, записанное в обход → принимается без единой проверки. +// +// Это тот же класс, что и удалённый SHA-224 fallback при входе: молчаливое +// «понимаем формат предыдущего поколения» превращается в молчаливое «понимаем +// что угодно». func DecryptPeerSecret(stored string) (string, error) { - if !strings.HasPrefix(stored, "v1:") { - return stored, nil + if !strings.HasPrefix(stored, peerSecretCipherPrefix) { + return "", fmt.Errorf( + "секрет пира хранится в неизвестном формате: ожидался префикс %q. "+ + "HY2XS хранит секреты пиров только зашифрованными", + peerSecretCipherPrefix, + ) } key, err := dao.GetOrCreatePeerSecretEncryptionKey() if err != nil { @@ -52,3 +81,8 @@ func DecryptPeerSecret(stored string) (string, error) { } return util.DecryptAESGCM(stored, key) } + +// peerSecretCipherPrefix — единственный поддерживаемый формат хранения. +// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и +// сообщение об ошибке не расходились с ним по разным файлам молча. +const peerSecretCipherPrefix = "v1:" diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md index cedfc39..96facba 100644 --- a/docs/02-build-layer-and-package.md +++ b/docs/02-build-layer-and-package.md @@ -147,11 +147,28 @@ Hysteria в шаблоне) плюс несовместимость `DefaultRow` Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире. -Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`, по той же логике, что и -`ALLOW_DIRTY_BUILD`: выпустить релиз, зная об уязвимости, можно, но это решение -человека, а не поведение по умолчанию. Результат шага уезжает в -`metadata/package.env` полем `dependency_security_gate`, так что по готовому -tarball видно, проверялся он или собран с пропущенной проверкой. +Аварийного выхода у шага **нет**, и это отличает его от `ALLOW_DIRTY_BUILD`. +Результат уезжает в `metadata/package.env` полем `dependency_security_gate`, +которое принимает единственное значение `true`: по готовому tarball видно, что +он проверялся, потому что непроверенного tarball не бывает. + +Две переменные обхода здесь существовали и были описаны как способ выпустить +релиз, зная об уязвимости. Способом они не были: финальная приёмка архива +требует буквально `dependency_security_gate=true`, поэтому сборка с любой из них +доходила до конца — компиляция, бандл, тесты, метаданные, tar — и падала на +последнем шаге. Продукт документировал операцию, которую сам же запрещал. +Противоречие закрыто в пользу строгой политики; отсутствие обходов проверяется +приёмкой, а не только описано здесь. + +Контракт читается однозначно: + +> релизный артефакт HY2XS невозможно собрать с непройденной проверкой +> зависимостей. + +Новое advisory чинится обновлением графа (`apps/go.sum`, +`apps/frontend/pnpm-lock.yaml`) или версии toolchain в `versions.env`. Для +локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и +`pnpm audit` запускаются напрямую и tarball не создают. ### Проверка, а не генерация diff --git a/docs/04-admin-panel.md b/docs/04-admin-panel.md index 6ebb362..48dead7 100644 --- a/docs/04-admin-panel.md +++ b/docs/04-admin-panel.md @@ -281,6 +281,36 @@ Denylist требует, чтобы автор каждого нового кл виде, поэтому запускается только через явное подтверждение с описанием риска. Такой файл следует хранить как пароль и удалять после завершения переноса. +#### Резервная копия либо полная, либо её нет + +Для `includeSecrets=true` правило строгое: если секрет хотя бы одного пира +получить не удалось — расшифровка не прошла или шифртекста нет вовсе — **весь** +запрос завершается ошибкой, называющей проблемного пира, и файл не создаётся. + +Раньше оба этих случая обрабатывались молча: пир уезжал в файл с пустым полем +`secret`, а запрос отвечал успехом. Оператор получал файл, выглядящий полным: + +```json +[{"name":"A","secret":"..."}, + {"name":"B","secret":""}, + {"name":"C","secret":"..."}] +``` + +Обнаруживалось это уже после импорта на новом сервере: B получал новый +сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима ровно в +том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»; +частичный результат под этим именем — худший из возможных ответов. + +Безопасная выгрузка (`includeSecrets=false`) шифртекст не трогает вовсе и +повреждённых данных не замечает: пустой `secret` там — не потеря, а весь смысл +режима. + +Секреты пиров хранятся только зашифрованными, в единственном формате `v1:` + +AES-GCM. Значение без этого префикса — не «формат предыдущего поколения», а +повреждённые данные, и расшифровка на них отказывает. Прежняя реализация +возвращала такое содержимое как якобы успешно расшифрованный секрет, то есть +мусор из колонки уходил и в клиентскую ссылку, и в резервную копию. + ### Импорт пиров Импорт проверяется так же строго, как обычное создание пира: те же правила для @@ -478,6 +508,23 @@ upstream выберет для нового секрета. Список мар запись, и только потом требуется переменная. Перезапуск уже установленного сервиса без неё работает штатно. +Тот же принцип распространён на machine token `HYSTERIA2_TRAFFIC_STATS_SECRET`. +Раньше при пустом env и пустой базе админка генерировала его сама, и это было +хуже, чем отказ: записать значение в `/etc/hysteria/config.yaml` она не может — +файл принадлежит оркестратору и доступен ей только на чтение, что проверяет +smoke. Результат — сервис объявлял себя здоровым, а machine auth переставал +совпадать, потому что Hysteria продолжала слать прежний токен. Допустимых +состояний три: + +| env | база | поведение | +| --- | --- | --- | +| задан | любое | база синхронизируется с env: владелец значения — оркестратор | +| пуст | токен есть | рабочее состояние, ничего не меняется | +| пуст | пусто | **отказ старта** | + +Вторая строка нужна для ручного `systemctl start` без `EnvironmentFile`: она не +изобретает контракт, а использует уже согласованный. + ### Пир установщика защищён во всех путях записи `bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя @@ -490,6 +537,56 @@ upstream выберет для нового секрета. Список мар обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке. +### Отзыв пира установщика необратим + +Разрешать удаление имеет смысл только вместе с этим свойством, иначе панель +предлагает операцию, которой не выполняет. + +Признаком «создавать пир или нет» служит отметка `BOOTSTRAP_PEER_SEEDED` в +таблице `config`. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не +«существует сейчас», и выставляется той же транзакцией, которой создаётся сам +пир. + +Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не +переживал перезапуск сервиса: + +```text +оператор удаляет bootstrap-admin-peer + ↓ +доступ действительно исчезает + +systemctl restart hy2xs-admin (или reboot) + ↓ +InitSql → ensureSecureBootstrapPeer + ↓ +строки нет → прочитать HY2XS_ADMIN_CON_PASS из /etc/hy2xs/hy2xs.env + ↓ +создать пира заново → ТОТ ЖЕ секрет снова действует +``` + +Переменная никуда не девается из `hy2xs.env` — её читает systemd-юнит, — поэтому +восстановление происходило **молча**: ни строки в журнале, а в списке пиров +запись просто снова есть. Отзыв учётных данных, который не переживает restart, +отзывом не является. + +Транзакционность здесь не формальность: раздельная запись вернула бы прежнее +поведение в новой форме, потому что падение процесса между созданием пира и +записью отметки снова дало бы следующему старту «ещё не создавался». + +Что при этом происходит с файлом на диске: `/etc/hy2xs/bootstrap-admin.secret` +принадлежит оркестратору, админка его не трогает, и после отзыва он содержит уже +недействующее значение. Это ожидаемо — файл является копией того, что установка +записала в базу, а не источником истины для рантайма. + +Отключение (`Disabled = 1`) остаётся вторым, обратимым способом: `Hysteria2Auth` +выбирает пира с условием `disabled = 0`, поэтому доступ закрывается сразу, а +запись сохраняется. + +Жизненный цикл закреплён тестами в `apps/dao/bootstrap_peer_test.go`: создание, +перезапуск без изменений, удаление с последующими перезапусками, отключение, +отказ старта без `HY2XS_ADMIN_CON_PASS` на чистой базе и успешный перезапуск без +неё на установленной. + ### Токены и пароли Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 — diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index b99f51b..4b2265a 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -369,6 +369,52 @@ wildcard-маршрутом фронтенда или дублирующая р - удаление **разрешено**: это осознанное действие оператора, и расхождения между базой и `bootstrap-admin.secret` оно не создаёт. +## A9e. Жизненный цикл пира установщика (unit, настоящая SQLite) + +`apps/dao/bootstrap_peer_test.go` — проверяется не функция, а поведение сервиса +при перезапуске: дефект, ради которого написан этот файл, проявлялся только на +ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность +`InitSqlAt` дважды на одной базе. + +- первый запуск создаёт пира и выставляет отметку `BOOTSTRAP_PEER_SEEDED`; +- обычный перезапуск не пересоздаёт пира и не плодит дублей (`id` тот же, + запись ровно одна); +- **удаление переживает перезапуск**: после `DELETE` и рестарта пир не + возвращается, хотя `HY2XS_ADMIN_CON_PASS` остаётся в окружении; +- то же после трёх перезапусков подряд; +- отключённый пир сохраняет `disabled = 1` и свой `secret_digest`; +- отметка и пир пишутся одной транзакцией: при конфликте `UNIQUE(name)` внутри + транзакции отметка не остаётся выставленной; +- отсутствие `HY2XS_ADMIN_CON_PASS` на чистой базе — отказ старта; +- перезапуск установленного сервиса без этой переменной проходит штатно; +- `HYSTERIA2_TRAFFIC_STATS_SECRET`: пустой env при пустой базе — отказ старта, + сгенерированного токена в базе не появляется; токен, уже согласованный + ранее, принимается без переменной. + +## A9f. Резервная копия пиров (unit) + +`apps/service/peer_export_backup_test.go`: + +- `includeSecrets=true` на исправных данных отдаёт секрет каждого пира; +- нерасшифровываемый секрет хотя бы одного пира отклоняет **весь** запрос, + сообщение называет пира, частичное содержимое не возвращается; +- пир вовсе без шифртекста — тот же отказ; +- `includeSecrets=false` повреждённых данных не замечает и пустой `secret` + отдаёт штатно: это и есть смысл безопасного режима. + +## A9g. Слой данных: «нет записи» против «база не ответила» (unit) + +`apps/dao/config_test.go`: + +- `UpdateConfig` по отсутствующей строке — **отказ**, а не тихий успех: UPDATE + без совпавших строк не является ошибкой SQL, и раньше оператор получал + подтверждение изменения, которого не произошло, а планировщик тут же получал + новое расписание; +- `UpdateConfig` не создаёт строк: это работа `UpsertConfigValue`; +- транзакционная партия откатывается целиком, если одна из строк отсутствует; +- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`, + отличимые через `errors.Is` от `ErrStorage`. + ## A10. Импорт пиров (unit) `apps/service/peer_import_test.go`: @@ -811,3 +857,12 @@ hy2xs-orchestrator doctor 32. документация нигде не утверждает, что Salamander — фиксированный инвариант 33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy 34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет» +35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения +36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает +37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска +38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен +39. админка не генерирует `HYSTERIA2_TRAFFIC_STATS_SECRET` сама: пустой env при пустой базе — отказ старта +40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации +41. `apps/go.mod` объявляет `toolchain`, совпадающий с `GO_VERSION` из `versions.env` +42. `tools/dev/doctor.sh` / `doctor.ps1` показывают расхождение среды разработки с `versions.env` +43. маршруты-алиасы `/:id/client-url` и `/:id/qr` удалены и не входят в публичный API v1 diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 9734522..c4a5441 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -253,6 +253,12 @@ Update the DNS A record before using this server. 3. дождаться истечения TTL; 4. повторить `hy2xs-orchestrator doctor`. +`doctor` безопасно запускать на работающем сервере: он не перезапускает +сервисы и живые соединения не рвёт. Раньше это было не так — команда звала +общий smoke, который начинается с `systemctl restart hysteria-server +hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех +подключённых клиентов. + Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index c8caf32..544ffcb 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -116,6 +116,22 @@ hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /e Команда выполняет preflight + smoke как post-install/post-reboot validation. +`doctor` **не перезапускает сервисы**: он диагностирует работающую установку. +Раньше он собирал контекст с параметрами по умолчанию и звал общий smoke, а тот +первым же действием выполняет `systemctl restart hysteria-server hy2xs-admin` — +то есть команда, которую этот раздел предлагает запускать при подозрении на +проблему, гарантированно обрывала все живые VPN-соединения, включая случай, +когда с сервисом всё в порядке. Диагностика, меняющая то, что диагностирует, +отвечает не на заданный вопрос: после рестарта проверяется уже другое состояние. + +Остальные проверки smoke выполняются полностью — слушатели, права и владельцы +файлов, machine auth (включая негативные случаи), семантика +`/etc/hysteria/config.yaml` против production-профиля, версия установленного +бинаря. Состояние сервера ни одна из них не меняет. + +Перезапуск сервисов остаётся операцией `install`, `reconfigure --apply` и +`repair` — там он является частью применения изменений, а не проверкой. + ## 13. Admin UI access via SSH tunnel Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается. diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index a1a2caa..3bf7e90 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -13,7 +13,8 @@ function usage(): never { console.error(" hy2xs-orchestrator install --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]"); console.error(" hy2xs-orchestrator reconfigure --package-dir [--config ] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator repair --package-dir [--config ] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); - console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); + console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-smoke]"); + console.error(" note: doctor never restarts services — it diagnoses the running installation"); console.error(" hy2xs-orchestrator status --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator diagnostics collect --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator redact-config --config [--in-place | --out ] [--format auto|env|yaml]"); diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts index 5a16149..5e8e353 100644 --- a/orchestrator/src/commands/doctor.ts +++ b/orchestrator/src/commands/doctor.ts @@ -57,6 +57,29 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise { } } +/** + * doctor — диагностика работающей установки, а не её починка. + * + * skipServiceStart здесь выставляется ПРИНУДИТЕЛЬНО, независимо от того, что + * пришло из командной строки. + * + * Что было. doctor собирал контекст с параметрами по умолчанию + * (`skipServiceStart: false`) и звал общий smoke, а тот первым же действием + * выполняет `systemctl restart hysteria-server hy2xs-admin`. То есть команда, + * которую docs/12 и docs/13 предлагают запускать ПРИ ПОДОЗРЕНИИ на проблему, + * гарантированно обрывала все живые VPN-соединения на сервере — включая + * ситуацию, когда с сервисом всё в порядке и оператор просто проверял состояние. + * + * Диагностика, меняющая то, что диагностирует, отвечает не на заданный вопрос: + * после рестарта проверяется уже другое состояние системы. Флаги + * --skip-service-start/--skip-smoke оставлены разбираемыми, чтобы вызов + * `doctor --skip-service-start` не падал на неизвестном аргументе, но повлиять + * они могут только в сторону сужения проверок. + * + * Остальная часть smoke — слушатели, права на файлы, machine auth, семантика + * /etc/hysteria/config.yaml, версия бинаря — состояние сервера не меняет и + * выполняется полностью. + */ export async function doctor(options: ReconfigureOptions): Promise { setOperationContext(`doctor-${Date.now().toString(36)}`); const configRaw = await readText(options.sourceConfigPath); @@ -64,7 +87,7 @@ export async function doctor(options: ReconfigureOptions): Promise { const context: ReconfigureContext = { mode: "reconfigure", - options: { ...options, dryRun: true, apply: false }, + options: { ...options, dryRun: true, apply: false, skipServiceStart: true }, config, packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), diff --git a/orchestrator/test/doctor-readonly.test.ts b/orchestrator/test/doctor-readonly.test.ts new file mode 100644 index 0000000..1dc7cab --- /dev/null +++ b/orchestrator/test/doctor-readonly.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * doctor обязан диагностировать установку, не изменяя её. + * + * Регрессия, которую закрепляет этот файл. doctor собирал контекст с + * `skipServiceStart` по умолчанию (false) и звал общий smoke, а тот первым же + * действием выполняет `systemctl restart hysteria-server hy2xs-admin`. Команда, + * которую runbook предлагает запускать при подозрении на проблему, обрывала все + * живые VPN-соединения на сервере — в том числе когда с сервисом всё в порядке. + * + * Проверяется исходник, а не поведение: единственная альтернатива — поднять + * настоящие systemd-юниты, чего в этой среде не существует. Утверждение при + * этом точное: рестарт в smoke стоит ровно под одним условием, и тест + * фиксирует, что doctor это условие закрывает безусловно. + */ +// Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и +// из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик +// обычно второе. +function source(relativeToSrc: string): string { + return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); +} + +describe("doctor не перезапускает сервисы", () => { + test("контекст doctor принудительно выставляет skipServiceStart", () => { + const doctorSource = source("commands/doctor.ts"); + expect(doctorSource).toContain("skipServiceStart: true"); + }); + + test("skipServiceStart выставляется ПОСЛЕ раскрытия options и не может быть перекрыт", () => { + const doctorSource = source("commands/doctor.ts"); + const spread = doctorSource.indexOf("...options"); + const forced = doctorSource.indexOf("skipServiceStart: true"); + + expect(spread).toBeGreaterThan(-1); + expect(forced).toBeGreaterThan(-1); + // Порядок в объектном литерале решает: `{ ...options, skipServiceStart: true }` + // игнорирует значение из командной строки, обратный порядок — нет. + expect(forced).toBeGreaterThan(spread); + }); + + test("smoke по-прежнему рестартует сервисы только под флагом", () => { + const smokeSource = source("steps/smoke.ts"); + const restart = "systemctl restart hysteria-server hy2xs-admin"; + + expect(smokeSource).toContain(restart); + // Ровно одно место рестарта, и оно под проверкой флага: иначе выставление + // skipServiceStart в doctor ничего бы не гарантировало. + expect(smokeSource.split(restart).length - 1).toBe(1); + + const guard = smokeSource.indexOf("if (!context.options.skipServiceStart)"); + const restartAt = smokeSource.indexOf(restart); + expect(guard).toBeGreaterThan(-1); + expect(guard).toBeLessThan(restartAt); + }); + + test("doctor не глушит остальные проверки smoke", () => { + const doctorSource = source("commands/doctor.ts"); + // skipSmoke приходит из options и по умолчанию false: doctor сужает только + // рестарт, а не диагностику целиком. + expect(doctorSource).not.toContain("skipSmoke: true"); + expect(doctorSource).toContain("await smoke(context)"); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index d0d6ba0..9fec843 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -12,8 +12,14 @@ set -euo pipefail # строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ # удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы # третьего раза не было. +# +# Отбрасываются оба вида строк-комментариев: `//` для Go и TypeScript, `#` для +# shell. Раньше стриплось только `//`, поэтому сканы по tools/build/*.sh не +# работали вовсе — там комментарии начинаются с решётки, и объяснение «почему +# этого больше нет» считалось за код. Ни один из языков не использует префикс +# другого для чего-то иного, так что общий фильтр безопасен для обоих. code_without_comments() { - grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true + grep -vE '^[[:space:]]*(//|#)' "$1" 2>/dev/null || true } # code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ. @@ -551,11 +557,67 @@ run_dependency_hygiene_acceptance() { grep -q '^GOVULNCHECK_VERSION=' versions.env \ || fail "acceptance: версия govulncheck должна быть частью контракта сборки" - # По готовому tarball должно быть видно, проверялся он или собран с - # пропущенной проверкой — ровно как hysteria_compat_gate. + # По готовому 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. diff --git a/tools/build/lib/security.sh b/tools/build/lib/security.sh index aca3acd..8d2204f 100644 --- a/tools/build/lib/security.sh +++ b/tools/build/lib/security.sh @@ -27,24 +27,40 @@ set -euo pipefail # production-зависимостями и порогом PNPM_AUDIT_LEVEL: # devDependencies в артефакт не попадают. -# Аварийный выход существует по той же причине, что и ALLOW_DIRTY_BUILD: новое -# advisory может выйти в неудачный момент, и решение «выпускаем, зная о нём» -# должно приниматься человеком ЯВНО, а не пропуском шага по умолчанию. -ALLOW_VULNERABLE_DEPENDENCIES="${ALLOW_VULNERABLE_DEPENDENCIES:-false}" - +# Аварийного выхода у этого шага НЕТ, и это осознанное решение. +# +# Раньше существовали два: ALLOW_VULNERABLE_DEPENDENCIES=true записывал в +# metadata `dependency_security_gate=accepted-risk`, SKIP_SECURITY_SCAN=true — +# `skipped`. Оба были описаны в README и docs/02 как способ выпустить релиз, +# зная об уязвимости. +# +# Способом они не были. Финальная приёмка архива требует буквально +# +# grep -q '^dependency_security_gate=true$' metadata/package.env +# +# то есть сборка с любым из этих значений доходила до самого конца — компиляция, +# бандл, тесты, метаданные, tar — и падала на последнем шаге. Продукт +# документировал операцию, которую сам же запрещал, а обнаруживалось это через +# полный цикл сборки. +# +# Из двух непротиворечивых вариантов выбран строгий: гейт обязателен, значение +# в metadata ровно одно. Контракт при этом читается однозначно: +# +# релизный артефакт HY2XS невозможно собрать с непройденной проверкой +# зависимостей. +# +# Для локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и +# `pnpm audit` запускаются напрямую и к созданию tarball отношения не имеют. security_gate_failed() { local scanner="$1" local details="$2" - if [ "$ALLOW_VULNERABLE_DEPENDENCIES" = "true" ]; then - log_info "WARNING: $scanner нашёл уязвимости, но сборка продолжена по ALLOW_VULNERABLE_DEPENDENCIES=true" - log_info "$details" - DEPENDENCY_SECURITY_GATE="accepted-risk" - export DEPENDENCY_SECURITY_GATE - return 0 - fi + fail "$scanner: найдены уязвимости в зависимостях. + +Обойти этот шаг нельзя: релизный пакет HY2XS собирается только с пройденной +проверкой. Обновите граф зависимостей (apps/go.sum, apps/frontend/pnpm-lock.yaml) +или версию toolchain в versions.env. - fail "$scanner: найдены уязвимости в зависимостях. Обновите граф или, осознанно принимая риск, выставьте ALLOW_VULNERABLE_DEPENDENCIES=true. $details" } @@ -106,25 +122,24 @@ $report" security_gate_failed "pnpm audit" "$report" } -# Результат шага уезжает в metadata/package.env: по готовому tarball должно -# быть видно, проверялся он или собран с пропущенной проверкой. Ровно так же -# устроен hysteria_compat_gate. +# Результат шага уезжает в metadata/package.env — так же, как hysteria_compat_gate. +# +# Значение у поля теперь ровно одно: `true`. Пропущенного состояния не бывает, +# потому что не бывает пакета, собранного с пропущенной проверкой; поле остаётся +# в metadata как утверждение о готовом артефакте, а не как переключатель. run_dependency_security_gate() { - if [ "${SKIP_SECURITY_SCAN:-false}" = "true" ]; then - log_info "WARNING: проверка зависимостей на уязвимости пропущена (SKIP_SECURITY_SCAN=true)" - DEPENDENCY_SECURITY_GATE="skipped" - export DEPENDENCY_SECURITY_GATE - return 0 - fi - [ -n "${GOVULNCHECK_VERSION:-}" ] \ || fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым" [ -n "${PNPM_AUDIT_LEVEL:-}" ] \ || fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым" - DEPENDENCY_SECURITY_GATE="true" - export DEPENDENCY_SECURITY_GATE - run_go_vulnerability_gate run_frontend_vulnerability_gate + + # Флаг выставляется ПОСЛЕ обеих проверок, а не до них. Разницы в поведении + # сейчас нет — обе ветки отказа завершают сборку, — но «утверждение о + # результате», записанное перед получением результата, рано или поздно + # переживает свою причину. + DEPENDENCY_SECURITY_GATE="true" + export DEPENDENCY_SECURITY_GATE } diff --git a/tools/build/lib/versions.sh b/tools/build/lib/versions.sh index 7892a9e..8b9aa4b 100644 --- a/tools/build/lib/versions.sh +++ b/tools/build/lib/versions.sh @@ -248,7 +248,7 @@ verify_api_namespace_contract() { } verify_go_toolchain_contract() { - local declared_go_line contract_minor go_minor + local declared_go_line contract_minor go_minor declared_toolchain declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')" [ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive" @@ -259,6 +259,16 @@ verify_go_toolchain_contract() { || fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION" [ "$go_minor" -le "$contract_minor" ] \ || fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION" + + # Директива `go` задаёт языковой baseline и НЕ выбирает компилятор: с ней + # одной локальная сборка на другой минорной линии проходит успешно, и + # разработчик проверяет не ту stdlib, которая уедет в production-бинарь. + # `toolchain` называет компилятор явно и обязан совпадать с versions.env + # точно, а не «быть не выше». + declared_toolchain="$(grep -E '^toolchain[[:space:]]+go[0-9]' apps/go.mod | head -n1 | awk '{print $2}')" + [ -n "$declared_toolchain" ] \ + || fail "versions contract: apps/go.mod has no 'toolchain' directive; add 'toolchain go${GO_VERSION}'" + expect_equal "apps/go.mod toolchain" "$declared_toolchain" "go${GO_VERSION}" } verify_versions_contract() { diff --git a/tools/dev/doctor.ps1 b/tools/dev/doctor.ps1 new file mode 100644 index 0000000..62fefd4 --- /dev/null +++ b/tools/dev/doctor.ps1 @@ -0,0 +1,130 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Проверка среды разработки против versions.env (Windows). + +.DESCRIPTION + Точный аналог tools/dev/doctor.sh: сборка релиза скачивает Go, Node и Bun + ровно тех версий, что записаны в versions.env, а машина разработчика не + проверялась никак — расхождение обнаруживалось только на Debian, внутри + release-сборки. + + Расхождение не гипотетическое: локальный Go 1.25 собирал проект успешно + (директива `go` в go.mod — языковой baseline, а не выбор компилятора), а + релизный бинарь компилировался на 1.26.7 и наследовал ЕЁ stdlib. То есть + проверялся не тот код, который уезжает в production. + + Скрипт ничего не устанавливает и не меняет. + +.PARAMETER Quiet + Печатать только итог. +#> + +[CmdletBinding()] +param( + [switch]$Quiet +) + +$ErrorActionPreference = 'Stop' + +$rootDir = Resolve-Path (Join-Path $PSScriptRoot '..' '..') +Set-Location $rootDir + +$versionsPath = Join-Path $rootDir 'versions.env' +if (-not (Test-Path $versionsPath)) { + Write-Error 'versions.env не найден: doctor запускается из репозитория HY2XS' +} + +# versions.env — POSIX shell, но по построению содержит только присваивания +# KEY=value и комментарии: это его объявленный контракт (см. шапку файла). +$contract = @{} +foreach ($line in Get-Content $versionsPath) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } + $separator = $trimmed.IndexOf('=') + if ($separator -lt 1) { continue } + $contract[$trimmed.Substring(0, $separator).Trim()] = $trimmed.Substring($separator + 1).Trim() +} + +$failures = 0 + +function Say([string]$Text) { + if (-not $Quiet) { Write-Host $Text } +} + +# Версия спрашивается у самого бинаря: что записано в PATH — не тот вопрос, +# отвечать должен тот инструмент, который реально запустится. +function Get-ToolVersion { + param([string]$Command, [string[]]$Arguments) + + $resolved = Get-Command $Command -ErrorAction SilentlyContinue + if (-not $resolved) { return 'not found' } + + try { + $output = & $resolved.Source @Arguments 2>$null | Select-Object -First 1 + } catch { + return 'unknown' + } + $match = [regex]::Match([string]$output, '\d+\.\d+\.\d+') + if (-not $match.Success) { return 'unknown' } + return $match.Value +} + +function Test-Contract { + param([string]$Label, [string]$Required, [string]$Found, [string]$Hint) + + if (-not $Found) { $Found = 'unknown' } + + Say '' + Say "${Label}:" + Say " required: $Required" + Say " found: $Found" + + if ($Found -eq $Required) { + Say ' OK' + return + } + + Say " FAIL — $Hint" + $script:failures++ +} + +Say 'HY2XS development environment' +Say "contract: versions.env (HY2XS $($contract.HY2XS_VERSION), release line $($contract.HY2XS_RELEASE_LINE))" + +# Go попадает в production-артефакт целиком: его stdlib компилируется в +# hy2xs-admin. Несовпадение здесь — не удобство, а разная поверхность атаки у +# разработчика и у релиза. +Test-Contract 'Go' $contract.GO_VERSION (Get-ToolVersion 'go' @('version')) ` + "локальный Go собирает не ту stdlib, что уедет в релиз; поставьте $($contract.GO_VERSION)" + +# Node живёт только на build-хосте, но собирает код, который уезжает в production. +Test-Contract 'Node' $contract.NODE_VERSION (Get-ToolVersion 'node' @('--version')) ` + "frontend-ассеты собираются другой версией Node; поставьте $($contract.NODE_VERSION)" + +Test-Contract 'pnpm' $contract.PNPM_VERSION (Get-ToolVersion 'pnpm' @('--version')) ` + "разрешение зависимостей frontend может разойтись с lock-файлом; поставьте $($contract.PNPM_VERSION)" + +# Bun физически входит в скомпилированный оркестратор (`bun build --compile`), +# поэтому его версия — часть артефакта, а не инструмента. +Test-Contract 'Bun' $contract.BUN_VERSION (Get-ToolVersion 'bun' @('--version')) ` + "runtime Bun входит в бинарь оркестратора; поставьте $($contract.BUN_VERSION)" + +# go.mod обязан называть компилятор явно: директива `go` этого не делает. +$declaredToolchain = (Select-String -Path (Join-Path $rootDir 'apps' 'go.mod') -Pattern '^toolchain\s+(go[0-9][^\s]*)' | + Select-Object -First 1).Matches.Groups[1].Value +if (-not $declaredToolchain) { $declaredToolchain = 'отсутствует' } + +Test-Contract 'apps/go.mod toolchain' "go$($contract.GO_VERSION)" $declaredToolchain ` + 'директива toolchain разошлась с versions.env; сборка отвергнет это в verify_go_toolchain_contract' + +Say '' +if ($failures -eq 0) { + Say 'Итог: среда разработки совпадает с контрактом сборки.' + exit 0 +} + +Say "Итог: расхождений — $failures. Локальные проверки идут не по тому toolchain," +Say 'которым собирается релиз.' +exit 1 diff --git a/tools/dev/doctor.sh b/tools/dev/doctor.sh new file mode 100755 index 0000000..399270b --- /dev/null +++ b/tools/dev/doctor.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Проверка среды разработки против versions.env. +# +# Зачем. +# +# versions.env — единый контракт «продукт / платформа / toolchain», и сборка +# релиза его соблюдает: она скачивает Go, Node и Bun ровно тех версий, что +# записаны здесь, сверяя контрольные суммы. Машина разработчика при этом не +# проверялась НИКАК, поэтому расхождение обнаруживалось только на Debian, внутри +# release-сборки, и выглядело как «у меня работало». +# +# Расхождение не гипотетическое: локальный Go 1.25 собирал проект успешно (`go` +# в go.mod — языковой baseline, а не выбор компилятора), а релизный бинарь +# компилировался на 1.26.7 и наследовал ЕЁ stdlib. То есть проверялся не тот +# код, который уезжает в production. +# +# Скрипт ничего не устанавливает и не меняет: он отвечает на один вопрос — +# совпадает ли эта машина с контрактом сборки. +# +# Использование: +# ./tools/dev/doctor.sh проверить и вернуть код выхода +# ./tools/dev/doctor.sh --quiet только итог + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +QUIET=false +for arg in "$@"; do + case "$arg" in + --quiet) QUIET=true ;; + -h|--help) + printf 'usage: %s [--quiet]\n' "$0" + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$arg" >&2 + exit 2 + ;; + esac +done + +[ -f versions.env ] || { + printf 'versions.env не найден: doctor запускается из репозитория HY2XS\n' >&2 + exit 2 +} + +# shellcheck disable=SC1091 +. ./versions.env + +FAILURES=0 + +say() { + [ "$QUIET" = true ] || printf '%s\n' "$1" +} + +# Версия извлекается из вывода самого инструмента: спрашивать `которая версия +# записана в PATH` бессмысленно, отвечать должен тот бинарь, который реально +# запустится. +tool_version() { + local command="$1" + shift + command -v "$command" >/dev/null 2>&1 || { + printf 'not found' + return 0 + } + "$command" "$@" 2>/dev/null | head -n1 | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 +} + +check() { + local label="$1" required="$2" found="$3" hint="$4" + + if [ -z "$found" ]; then + found="unknown" + fi + + say "" + say "$label:" + say " required: $required" + say " found: $found" + + if [ "$found" = "$required" ]; then + say " OK" + return 0 + fi + + say " FAIL — $hint" + FAILURES=$((FAILURES + 1)) +} + +say "HY2XS development environment" +say "contract: versions.env (HY2XS $HY2XS_VERSION, release line $HY2XS_RELEASE_LINE)" + +# Go попадает в production-артефакт целиком: его stdlib компилируется в +# hy2xs-admin. Несовпадение здесь — не удобство, а разная поверхность атаки у +# разработчика и у релиза. +check "Go" "$GO_VERSION" "$(tool_version go version)" \ + "локальный Go собирает не ту stdlib, что уедет в релиз; поставьте $GO_VERSION" + +# Node живёт только на build-хосте (сборка frontend-ассетов), но собирает код, +# который уезжает в production. +check "Node" "$NODE_VERSION" "$(tool_version node --version)" \ + "frontend-ассеты собираются другой версией Node; поставьте $NODE_VERSION" + +check "pnpm" "$PNPM_VERSION" "$(tool_version pnpm --version)" \ + "разрешение зависимостей frontend может разойтись с lock-файлом; поставьте $PNPM_VERSION" + +# Bun физически входит в скомпилированный оркестратор (`bun build --compile`), +# поэтому его версия — часть артефакта, а не инструмента. +check "Bun" "$BUN_VERSION" "$(tool_version bun --version)" \ + "runtime Bun входит в бинарь оркестратора; поставьте $BUN_VERSION" + +# go.mod обязан называть компилятор явно: директива `go` этого не делает. +DECLARED_TOOLCHAIN="$(grep -E '^toolchain[[:space:]]+go[0-9]' apps/go.mod | head -n1 | awk '{print $2}' || true)" +check "apps/go.mod toolchain" "go$GO_VERSION" "${DECLARED_TOOLCHAIN:-отсутствует}" \ + "директива toolchain разошлась с versions.env; сборка отвергнет это в verify_go_toolchain_contract" + +say "" +if [ "$FAILURES" -eq 0 ]; then + say "Итог: среда разработки совпадает с контрактом сборки." + exit 0 +fi + +say "Итог: расхождений — $FAILURES. Локальные проверки идут не по тому toolchain," +say "которым собирается релиз." +exit 1