Files
HY2XS_flamy/apps/dao/config.go
T
founder cf094f6e6f 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.
2026-08-30 06:48:50 +05:00

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
}