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:
+10
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+13
-9
@@ -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
|
||||
}
|
||||
|
||||
+180
-37
@@ -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 {
|
||||
|
||||
+6
-8
@@ -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
|
||||
}
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+144
-18
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}$`)
|
||||
|
||||
@@ -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:"
|
||||
|
||||
Reference in New Issue
Block a user