cf094f6e6f
Проход по операциям, которые делают не то, что обещает их имя. P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config: «создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной транзакцией. P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь недоступный секрет любого пира отклоняет весь запрос с указанием имени. P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет, если оно не начиналось с v1: — остаток поколения с открытыми секретами. P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на проблему обрывала все живые соединения. P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в /etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine auth переставал совпадать. P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и документации, отсутствие проверяется приёмкой. P2. UPDATE по отсутствующей строке config считался успехом, и cron перепланировался при несохранённом значении. Решение по RowsAffected. P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage. P2. Удалены алиасы /:id/client-url и /:id/qr. Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go — языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют среду с versions.env.
181 lines
8.3 KiB
Go
181 lines
8.3 KiB
Go
package dao
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
"hy2xs-admin/model/entity"
|
|
)
|
|
|
|
func SaveConfig(config entity.Config) (int64, error) {
|
|
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, ErrStorage
|
|
}
|
|
return *config.Id, nil
|
|
}
|
|
|
|
func UpdateConfig(keys []string, updates map[string]interface{}) error {
|
|
return updateConfigOn(sqliteDB, keys, updates)
|
|
}
|
|
|
|
// UpsertConfigValue записывает значение ключа, создавая строку, если её нет.
|
|
//
|
|
// Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе
|
|
// были неверны:
|
|
//
|
|
// 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой
|
|
// загрузке печатало в журнал `duplicated key not allowed` уровня error, по
|
|
// одной строке на секрет. Оператор видел три ошибки на совершенно здоровом
|
|
// старте и не имел способа отличить их от настоящих;
|
|
//
|
|
// 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки
|
|
// не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает
|
|
// nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий
|
|
// получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET
|
|
// это означало бы подпись токенов ключом, которого нет в базе. Спасала
|
|
// только предварительная вставка пустых строк в seedBaseConfig — то есть
|
|
// корректность держалась на порядке выполнения миграций.
|
|
//
|
|
// Здесь решение принимается по RowsAffected, а не по наличию ошибки.
|
|
func UpsertConfigValue(key string, value string) error {
|
|
if sqliteDB == nil {
|
|
return errors.New("sqlite is not initialized")
|
|
}
|
|
return upsertConfigValueOn(sqliteDB, key, value)
|
|
}
|
|
|
|
// 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,
|
|
"update_time": time.Now().Format("2006-01-02 15:04:05"),
|
|
})
|
|
if tx.Error != nil {
|
|
logrus.Errorf("%v", tx.Error)
|
|
return ErrStorage
|
|
}
|
|
if tx.RowsAffected > 0 {
|
|
return nil
|
|
}
|
|
|
|
remark := key
|
|
if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConfigTx — записи в таблицу `config` в границах одной единицы работы.
|
|
//
|
|
// Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть
|
|
// доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна
|
|
// и та же для глобального соединения и для транзакции, поэтому «обычный» и
|
|
// «транзакционный» пути не могут разойтись в поведении.
|
|
type ConfigTx interface {
|
|
UpdateConfig(keys []string, updates map[string]interface{}) error
|
|
}
|
|
|
|
type configTx struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// WithConfigTx выполняет fn в одной транзакции: либо применяются все записи
|
|
// партии, либо ни одной.
|
|
//
|
|
// Что было. updateConfigs валидировал и записывал настройки в одном цикле:
|
|
// проверил элемент — записал, проверил следующий. Партия из разрешённого
|
|
// ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть
|
|
// оператор получал отказ на запрос, который систему уже изменил. Тест на этот
|
|
// случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу,
|
|
// поэтому поймать частичное применение был неспособен по построению.
|
|
func WithConfigTx(fn func(tx ConfigTx) error) error {
|
|
if sqliteDB == nil {
|
|
return errors.New("sqlite is not initialized")
|
|
}
|
|
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
|
return fn(configTx{db: tx})
|
|
})
|
|
}
|
|
|
|
func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error {
|
|
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")
|
|
tx := db.Model(&entity.Config{}).
|
|
Where("key in ?", keys).
|
|
Updates(updates)
|
|
if tx.Error != nil {
|
|
logrus.Errorf("%v", tx.Error)
|
|
return ErrStorage
|
|
}
|
|
if tx.RowsAffected == 0 {
|
|
return fmt.Errorf(
|
|
"config: не найдено ни одной строки для ключей %s; настройка не сохранена",
|
|
strings.Join(keys, ", "),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
|
return config, ErrConfigNotFound
|
|
}
|
|
logrus.Errorf("%v", tx.Error)
|
|
return config, ErrStorage
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) {
|
|
var configs []entity.Config
|
|
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, ErrStorage
|
|
}
|
|
return configs, nil
|
|
}
|