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.
140 lines
5.3 KiB
Go
140 lines
5.3 KiB
Go
package dao
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"github.com/sirupsen/logrus"
|
||
"gorm.io/gorm"
|
||
"hy2xs-admin/model/dto"
|
||
"hy2xs-admin/model/entity"
|
||
"time"
|
||
)
|
||
|
||
// PeerTx — операции над пирами в границах одной единицы работы.
|
||
//
|
||
// Интерфейс существует, чтобы транзакция была доступна слою service, но
|
||
// *gorm.DB туда не протекал: выбор ORM остаётся деталью dao. Реализация одна и
|
||
// та же и для глобального соединения, и для транзакции — за счёт этого
|
||
// невозможна ситуация, когда «обычный» путь и «транзакционный» расходятся в
|
||
// поведении.
|
||
type PeerTx interface {
|
||
GetPeer(query interface{}, args ...interface{}) (entity.Peer, error)
|
||
SavePeer(peer entity.Peer) (int64, error)
|
||
UpdatePeer(ids []int64, updates map[string]interface{}) error
|
||
}
|
||
|
||
type peerTx struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// WithPeerTx выполняет fn в одной транзакции: либо применяются все записи,
|
||
// либо ни одной.
|
||
//
|
||
// Что было. Импорт пиров валидировал партию целиком до первой записи, но сами
|
||
// записи шли по одной, каждая своим оператором. Комментарий обещал «либо
|
||
// целиком, либо никак», а на практике UNIQUE-конфликт на 37-й записи оставлял
|
||
// 36 применённых. Конфликт при этом совершенно реальный: если в базе есть
|
||
// A(auth_id=a, name=alice) и B(auth_id=b, name=bob), а импорт несёт
|
||
// (auth_id=a, name=bob), то поиск найдёт A по auth_id и попытается
|
||
// переименовать его в bob — прямо в UNIQUE(name).
|
||
func WithPeerTx(fn func(tx PeerTx) error) error {
|
||
if sqliteDB == nil {
|
||
return errors.New("sqlite is not initialized")
|
||
}
|
||
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||
return fn(peerTx{db: tx})
|
||
})
|
||
}
|
||
|
||
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, ErrStorage
|
||
}
|
||
return *peer.Id, nil
|
||
}
|
||
|
||
func (p peerTx) UpdatePeer(ids []int64, 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 := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||
logrus.Errorf("%v", tx.Error)
|
||
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 errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||
return peer, ErrPeerNotFound
|
||
}
|
||
logrus.Errorf("%v", tx.Error)
|
||
return peer, ErrStorage
|
||
}
|
||
return peer, nil
|
||
}
|
||
|
||
// global — операции вне транзакции. Одна и та же реализация, другое соединение.
|
||
func global() peerTx {
|
||
return peerTx{db: sqliteDB}
|
||
}
|
||
|
||
func SavePeer(peer entity.Peer) (int64, error) {
|
||
return global().SavePeer(peer)
|
||
}
|
||
|
||
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||
return global().UpdatePeer(ids, updates)
|
||
}
|
||
|
||
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||
return global().GetPeer(query, args...)
|
||
}
|
||
|
||
func DeletePeer(ids []int64) error {
|
||
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
||
logrus.Errorf("%v", tx.Error)
|
||
return ErrStorage
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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, ErrStorage
|
||
}
|
||
return peers, nil
|
||
}
|
||
|
||
func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
|
||
var peers []entity.Peer
|
||
var total int64
|
||
tx := sqliteDB.Model(&entity.Peer{})
|
||
if peerPageDto.Name != nil && *peerPageDto.Name != "" {
|
||
tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Name))
|
||
}
|
||
if peerPageDto.Disabled != nil {
|
||
tx.Where("disabled = ?", *peerPageDto.Disabled)
|
||
}
|
||
if peerPageDto.Remark != nil && *peerPageDto.Remark != "" {
|
||
tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Remark))
|
||
}
|
||
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, ErrStorage
|
||
}
|
||
return peers, total, nil
|
||
}
|