fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам
Проход по операциям, которые делают не то, что обещает их имя. 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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user