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

Установка

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

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

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

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

Секреты

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

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

Пиры

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

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

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

Сборка

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

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

Очистка

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

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

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

268 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/entity"
)
// Тесты транзакционности импорта работают на настоящей SQLite: UNIQUE-конфликт,
// ради которого транзакция и нужна, воспроизводится только в базе. Схема
// поднимается отдельно от InitSql — bootstrap-администратор и bootstrap-пир
// здесь не нужны и только мешали бы читать ожидания.
func newTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() {
_ = dao.CloseSqliteDB()
})
}
func seedPeer(t *testing.T, name string, authID string) int64 {
t.Helper()
digest, err := PeerSecretDigest(name + "-secret")
if err != nil {
t.Fatalf("digest: %v", err)
}
cipher, err := EncryptPeerSecret(name + "-secret")
if err != nil {
t.Fatalf("encrypt: %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: &quota,
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 snapshotPeers(t *testing.T) map[string]entity.Peer {
t.Helper()
peers, err := dao.ListPeer("1=1")
if err != nil {
t.Fatalf("не удалось прочитать пиров: %v", err)
}
out := make(map[string]entity.Peer, len(peers))
for _, peer := range peers {
out[*peer.Name] = peer
}
return out
}
func importItem(name string, authID string) bo.PeerExport {
return bo.PeerExport{
Name: name,
AuthId: authID,
QuotaBytes: -1,
MaxDevices: 3,
}
}
func TestUpsertPeerExportAppliesWholeBatch(t *testing.T) {
newTestDB(t)
items := []bo.PeerExport{
importItem("alpha1", ""),
importItem("bravo2", ""),
importItem("charl3", ""),
}
if err := UpsertPeerExport(items); err != nil {
t.Fatalf("валидный импорт отклонён: %v", err)
}
peers := snapshotPeers(t)
if len(peers) != 3 {
t.Fatalf("ожидалось 3 пира, получено %d", len(peers))
}
for _, name := range []string{"alpha1", "bravo2", "charl3"} {
if _, ok := peers[name]; !ok {
t.Errorf("пир %s не создан", name)
}
}
}
// Главная регрессия: cross-conflict, описанный в комментарии к UpsertPeerExport.
//
// В базе A(auth_id=aaa, name=alice1) и B(auth_id=bbb, name=bob123).
// Импорт несёт запись (auth_id=aaa, name=bob123): поиск найдёт A по auth_id и
// попытается переименовать её в bob123 — прямо в UNIQUE(name).
//
// До транзакции всё, что шло в файле ДО конфликтной записи, оставалось
// применённым.
func TestUpsertPeerExportRollsBackCrossConflict(t *testing.T) {
newTestDB(t)
seedPeer(t, "alice1", "aaa")
seedPeer(t, "bob123", "bbb")
before := snapshotPeers(t)
items := []bo.PeerExport{
importItem("first1", ""), // применилась бы
importItem("second", ""), // применилась бы
importItem("bob123", "aaa"), // UNIQUE(name) конфликт
importItem("fourth", ""), // до неё дело не дойдёт
}
err := UpsertPeerExport(items)
if err == nil {
t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён")
}
// Отказ обязан прийти из базы, а не из валидации: иначе тест доказывал бы
// только то, что файл не прошёл проверку, и про откат не говорил бы ничего.
if strings.HasPrefix(err.Error(), "peer import:") {
t.Fatalf("ожидался отказ базы, получен отказ валидации: %v", err)
}
after := snapshotPeers(t)
if len(after) != len(before) {
t.Fatalf("состояние базы изменилось: было %d пиров, стало %d", len(before), len(after))
}
for name, peer := range before {
got, ok := after[name]
if !ok {
t.Errorf("пир %s пропал после отката", name)
continue
}
if *got.AuthId != *peer.AuthId {
t.Errorf("у пира %s изменился auth_id: %s -> %s", name, *peer.AuthId, *got.AuthId)
}
}
for _, name := range []string{"first1", "second", "fourth"} {
if _, ok := after[name]; ok {
t.Errorf("частично применённый импорт: пир %s остался в базе", name)
}
}
}
// Тот же инвариант на дубликате auth_id: конфликт возникает на вставке, а не
// на обновлении.
func TestUpsertPeerExportRollsBackDuplicateAuthId(t *testing.T) {
newTestDB(t)
seedPeer(t, "exist1", "shared-auth-id")
before := snapshotPeers(t)
items := []bo.PeerExport{
importItem("newone", ""),
// Имя новое, поэтому запись будет вставляться, а auth_id уже занят.
importItem("newtwo", "shared-auth-id"),
}
err := UpsertPeerExport(items)
if err == nil {
// Совпадение по auth_id приводит к обновлению существующего пира —
// это допустимая семантика upsert. Тогда проверяем, что первая запись
// применилась вместе со второй, а не вместо неё.
after := snapshotPeers(t)
if _, ok := after["newone"]; !ok {
t.Fatal("успешный импорт не применил первую запись")
}
return
}
after := snapshotPeers(t)
if len(after) != len(before) {
t.Fatalf("частичный импорт: было %d пиров, стало %d", len(before), len(after))
}
if _, ok := after["newone"]; ok {
t.Error("частично применённый импорт: пир newone остался в базе")
}
}
// Отказ подготовки (валидация партии) не должен доходить до базы вовсе.
func TestUpsertPeerExportRejectsInvalidBatchBeforeAnyWrite(t *testing.T) {
newTestDB(t)
items := []bo.PeerExport{
importItem("valid1", ""),
importItem("bad", ""), // короче 6 символов
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("партия с невалидной записью должна быть отклонена")
}
if peers := snapshotPeers(t); len(peers) != 0 {
t.Fatalf("отклонённая партия оставила записи в базе: %d", len(peers))
}
}
// Пир установщика защищён и внутри транзакции: отказ обязан откатить всё, что
// успело примениться до него.
func TestUpsertPeerExportRollsBackOnBootstrapPeer(t *testing.T) {
newTestDB(t)
seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
items := []bo.PeerExport{
importItem("first1", ""),
// Имя зарезервировано, поэтому валидация не пропустит его напрямую;
// совпадение достигается через auth_id установщика.
importItem("second", ReservedBootstrapPeerName),
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("импорт, задевающий пира установщика, должен быть отклонён")
}
after := snapshotPeers(t)
if _, ok := after["first1"]; ok {
t.Error("частично применённый импорт: пир first1 остался в базе")
}
}
// Обновление существующего пира без секрета в файле не должно трогать его
// секрет: иначе перенос настроек ломал бы работающие клиентские ссылки.
func TestUpsertPeerExportKeepsSecretWhenFileHasNone(t *testing.T) {
newTestDB(t)
seedPeer(t, "keeper", "keeper-auth")
before := snapshotPeers(t)["keeper"]
item := importItem("keeper", "keeper-auth")
item.Remark = "updated remark"
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отклонён: %v", err)
}
after := snapshotPeers(t)["keeper"]
if *after.SecretDigest != *before.SecretDigest {
t.Error("секрет пира перезаписан, хотя в файле его не было")
}
if *after.Remark != "updated remark" {
t.Errorf("remark не обновился: %q", *after.Remark)
}
}