cf094f6e6f
Проход по операциям, которые делают не то, что обещает их имя. P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config: «создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной транзакцией. P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь недоступный секрет любого пира отклоняет весь запрос с указанием имени. P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет, если оно не начиналось с v1: — остаток поколения с открытыми секретами. P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на проблему обрывала все живые соединения. P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в /etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine auth переставал совпадать. P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и документации, отсутствие проверяется приёмкой. P2. UPDATE по отсутствующей строке config считался успехом, и cron перепланировался при несохранённом значении. Решение по RowsAffected. P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage. P2. Удалены алиасы /:id/client-url и /:id/qr. Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go — языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют среду с versions.env.
331 lines
12 KiB
Go
331 lines
12 KiB
Go
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)
|
|
}
|
|
}
|