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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user