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.
646 lines
25 KiB
Go
646 lines
25 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"hy2xs-admin/dao"
|
||
"hy2xs-admin/model/bo"
|
||
"hy2xs-admin/model/constant"
|
||
"hy2xs-admin/model/dto"
|
||
"hy2xs-admin/model/entity"
|
||
"hy2xs-admin/model/vo"
|
||
"hy2xs-admin/util"
|
||
)
|
||
|
||
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||
peers, total, err := dao.PagePeer(peerPageDto)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
onlineUsers, _ := Hysteria2Online()
|
||
result := make([]vo.PeerVo, 0, len(peers))
|
||
for _, p := range peers {
|
||
item := vo.PeerVo{
|
||
BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
|
||
Name: strVal(p.Name),
|
||
Remark: strVal(p.Remark),
|
||
AuthId: strVal(p.AuthId),
|
||
QuotaBytes: int64Val(p.QuotaBytes),
|
||
DownloadBytes: int64Val(p.DownloadBytes),
|
||
UploadBytes: int64Val(p.UploadBytes),
|
||
ExpiresAt: int64Val(p.ExpiresAt),
|
||
MaxDevices: int64Val(p.MaxDevices),
|
||
Disabled: int64Val(p.Disabled),
|
||
BannedUntil: int64Val(p.BannedUntil),
|
||
LastConnectionAt: int64Val(p.LastConnectionAt),
|
||
}
|
||
authID := strVal(p.AuthId)
|
||
if v, ok := onlineUsers[authID]; ok {
|
||
item.Online = true
|
||
item.OnlineDevices = v
|
||
}
|
||
result = append(result, item)
|
||
}
|
||
return result, total, nil
|
||
}
|
||
|
||
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
|
||
//
|
||
// Что было. ReservedBootstrapPeerName проверялся исключительно в
|
||
// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть
|
||
// ровно то, ради чего guard существует, делалось через интерфейс: секрет
|
||
// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret,
|
||
// откуда его читает проверка machine-auth оркестратора, и смена секрета через
|
||
// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две,
|
||
// а замок висел на одной.
|
||
//
|
||
// Границы намеренно разные для разных операций:
|
||
//
|
||
// идентичность и секрет — запрещены: они и есть то, что дублируется в файле;
|
||
// удаление и отключение — разрешены: это осознанные действия оператора,
|
||
// результат которых виден в списке пиров. Bootstrap-пир после установки
|
||
// остаётся действующим доступом, и запрет его убрать означал бы вечный
|
||
// неотзываемый доступ.
|
||
var errBootstrapPeerIdentity = fmt.Errorf(
|
||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||
ReservedBootstrapPeerName,
|
||
)
|
||
|
||
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||
if peerDto.Name == nil || *peerDto.Name == "" {
|
||
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
||
}
|
||
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
|
||
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
|
||
// защитой, предназначенной не ему.
|
||
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||
}
|
||
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 != "" {
|
||
secret = *peerDto.Secret
|
||
} else {
|
||
generated, err := util.RandomString(24)
|
||
if err != nil {
|
||
return vo.PeerVo{}, err
|
||
}
|
||
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
|
||
}
|
||
authId, err := util.RandomString(18)
|
||
if err != nil {
|
||
return vo.PeerVo{}, err
|
||
}
|
||
secretDigest, err := PeerSecretDigest(secret)
|
||
if err != nil {
|
||
return vo.PeerVo{}, err
|
||
}
|
||
secretEncrypted, err := EncryptPeerSecret(secret)
|
||
if err != nil {
|
||
return vo.PeerVo{}, err
|
||
}
|
||
peer := entity.Peer{
|
||
Name: peerDto.Name,
|
||
Remark: peerDto.Remark,
|
||
AuthId: &authId,
|
||
SecretDigest: &secretDigest,
|
||
SecretEncrypted: &secretEncrypted,
|
||
QuotaBytes: peerDto.QuotaBytes,
|
||
ExpiresAt: peerDto.ExpiresAt,
|
||
MaxDevices: peerDto.MaxDevices,
|
||
Disabled: peerDto.Disabled,
|
||
}
|
||
id, saveErr := dao.SavePeer(peer)
|
||
if saveErr != nil {
|
||
return vo.PeerVo{}, saveErr
|
||
}
|
||
return GetPeerVo(id)
|
||
}
|
||
|
||
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
|
||
return err
|
||
}
|
||
|
||
updates := map[string]interface{}{}
|
||
if peerDto.Name != nil && *peerDto.Name != "" {
|
||
updates["name"] = *peerDto.Name
|
||
}
|
||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||
digest, err := PeerSecretDigest(*peerDto.Secret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
enc, err := EncryptPeerSecret(*peerDto.Secret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
updates["secret_digest"] = digest
|
||
updates["secret_ciphertext"] = enc
|
||
}
|
||
if peerDto.QuotaBytes != nil {
|
||
updates["quota_bytes"] = *peerDto.QuotaBytes
|
||
}
|
||
if peerDto.ExpiresAt != nil {
|
||
updates["expires_at"] = *peerDto.ExpiresAt
|
||
}
|
||
if peerDto.MaxDevices != nil {
|
||
updates["max_devices"] = *peerDto.MaxDevices
|
||
}
|
||
if peerDto.Disabled != nil {
|
||
updates["disabled"] = *peerDto.Disabled
|
||
}
|
||
if peerDto.Remark != nil {
|
||
updates["remark"] = *peerDto.Remark
|
||
}
|
||
return dao.UpdatePeer([]int64{id}, updates)
|
||
}
|
||
|
||
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
|
||
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
|
||
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error {
|
||
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
|
||
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
|
||
// существует.
|
||
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||
existing, err := dao.GetPeer("id = ?", id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||
}
|
||
}
|
||
|
||
existing, err := dao.GetPeer("id = ?", id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||
return nil
|
||
}
|
||
|
||
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" &&
|
||
strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName {
|
||
return errBootstrapPeerIdentity
|
||
}
|
||
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
|
||
return errBootstrapPeerIdentity
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeletePeer удаляет пира, включая bootstrap-пира установщика.
|
||
//
|
||
// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный
|
||
// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор
|
||
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
|
||
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
|
||
// списке.
|
||
//
|
||
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
|
||
// отметкой 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) {
|
||
p, err := dao.GetPeer("id = ?", id)
|
||
if err != nil {
|
||
return vo.PeerVo{}, err
|
||
}
|
||
return vo.PeerVo{
|
||
BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
|
||
Name: strVal(p.Name),
|
||
Remark: strVal(p.Remark),
|
||
AuthId: strVal(p.AuthId),
|
||
QuotaBytes: int64Val(p.QuotaBytes),
|
||
DownloadBytes: int64Val(p.DownloadBytes),
|
||
UploadBytes: int64Val(p.UploadBytes),
|
||
ExpiresAt: int64Val(p.ExpiresAt),
|
||
MaxDevices: int64Val(p.MaxDevices),
|
||
Disabled: int64Val(p.Disabled),
|
||
BannedUntil: int64Val(p.BannedUntil),
|
||
LastConnectionAt: int64Val(p.LastConnectionAt),
|
||
}, nil
|
||
}
|
||
|
||
func ResetPeerTraffic(id int64) error {
|
||
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0})
|
||
}
|
||
func ReleaseKickPeer(id int64) error {
|
||
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
|
||
}
|
||
|
||
func KickPeer(id int64, bannedUntil int64) error {
|
||
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
|
||
return err
|
||
}
|
||
return Hysteria2Kick([]int64{id}, bannedUntil)
|
||
}
|
||
|
||
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
||
url, err := Hysteria2Url(id)
|
||
if err != nil {
|
||
return vo.PeerClientConfigVo{}, err
|
||
}
|
||
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, err
|
||
}
|
||
out := make([]bo.PeerExport, 0, len(peers))
|
||
for _, item := range peers {
|
||
ex := bo.PeerExport{
|
||
Id: int64Val(item.Id),
|
||
AuthId: strVal(item.AuthId),
|
||
Name: strVal(item.Name),
|
||
Remark: strVal(item.Remark),
|
||
QuotaBytes: int64Val(item.QuotaBytes),
|
||
DownloadBytes: int64Val(item.DownloadBytes),
|
||
UploadBytes: int64Val(item.UploadBytes),
|
||
ExpiresAt: int64Val(item.ExpiresAt),
|
||
MaxDevices: int64Val(item.MaxDevices),
|
||
Disabled: int64Val(item.Disabled),
|
||
BannedUntil: int64Val(item.BannedUntil),
|
||
LastConnectionAt: int64Val(item.LastConnectionAt),
|
||
}
|
||
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 — запись импорта со всем криптоматериалом, посчитанным
|
||
// заранее.
|
||
//
|
||
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
|
||
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
|
||
// базу; делать это, удерживая открытую запись, значит без нужды держать
|
||
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
|
||
// остаться только работа с таблицей пиров.
|
||
type preparedPeerImport struct {
|
||
source bo.PeerExport
|
||
|
||
name string
|
||
authID string
|
||
remark string
|
||
quota int64
|
||
expires int64
|
||
maxDevices int64
|
||
disabled int64
|
||
|
||
// Задан, только если секрет пришёл в файле: у существующего пира секрет
|
||
// перезаписывается лишь в этом случае.
|
||
hasExplicitSecret bool
|
||
explicitDigest string
|
||
explicitCipher string
|
||
|
||
// Готовятся всегда: понадобятся, если запись окажется новой.
|
||
createDigest string
|
||
createCipher string
|
||
createAuthID string
|
||
}
|
||
|
||
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||
prepared := make([]preparedPeerImport, 0, len(items))
|
||
|
||
for _, item := range items {
|
||
name := strings.TrimSpace(item.Name)
|
||
authID := strings.TrimSpace(item.AuthId)
|
||
|
||
maxDevices := item.MaxDevices
|
||
if maxDevices <= 0 {
|
||
maxDevices = 3
|
||
}
|
||
|
||
entry := preparedPeerImport{
|
||
source: item,
|
||
name: name,
|
||
authID: authID,
|
||
remark: item.Remark,
|
||
quota: item.QuotaBytes,
|
||
expires: item.ExpiresAt,
|
||
maxDevices: maxDevices,
|
||
disabled: item.Disabled,
|
||
}
|
||
|
||
explicitSecret := strings.TrimSpace(item.Secret)
|
||
if explicitSecret != "" {
|
||
digest, err := PeerSecretDigest(explicitSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cipher, err := EncryptPeerSecret(explicitSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
entry.hasExplicitSecret = true
|
||
entry.explicitDigest = digest
|
||
entry.explicitCipher = cipher
|
||
entry.createDigest = digest
|
||
entry.createCipher = cipher
|
||
} else {
|
||
generated, err := util.RandomString(24)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||
digest, err := PeerSecretDigest(createSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cipher, err := EncryptPeerSecret(createSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
entry.createDigest = digest
|
||
entry.createCipher = cipher
|
||
}
|
||
|
||
entry.createAuthID = authID
|
||
if entry.createAuthID == "" {
|
||
generated, err := util.RandomString(18)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
entry.createAuthID = generated
|
||
}
|
||
|
||
prepared = append(prepared, entry)
|
||
}
|
||
|
||
return prepared, nil
|
||
}
|
||
|
||
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
|
||
//
|
||
// Три прохода, и каждый отвечает за своё:
|
||
//
|
||
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
|
||
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
|
||
// 3. одна транзакция — только записи.
|
||
//
|
||
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
|
||
// оператором. Комментарий обещал «либо целиком, либо никак», но 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 UpsertPeerExport(items []bo.PeerExport) error {
|
||
if err := ValidatePeerImportBatch(items); err != nil {
|
||
return err
|
||
}
|
||
|
||
prepared, err := preparePeerImport(items)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return dao.WithPeerTx(func(tx dao.PeerTx) error {
|
||
for _, entry := range prepared {
|
||
if err := applyPeerImportEntry(tx, entry); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
||
existing, found, err := findPeerForImport(tx, entry)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||
return fmt.Errorf(
|
||
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
||
ReservedBootstrapPeerName,
|
||
)
|
||
}
|
||
|
||
if found {
|
||
updates := map[string]interface{}{
|
||
"name": entry.name,
|
||
"remark": entry.remark,
|
||
"quota_bytes": entry.quota,
|
||
"download_bytes": entry.source.DownloadBytes,
|
||
"upload_bytes": entry.source.UploadBytes,
|
||
"expires_at": entry.expires,
|
||
"max_devices": entry.maxDevices,
|
||
"disabled": entry.disabled,
|
||
"banned_until": entry.source.BannedUntil,
|
||
"last_connection_at": entry.source.LastConnectionAt,
|
||
}
|
||
if entry.authID != "" {
|
||
updates["auth_id"] = entry.authID
|
||
}
|
||
if entry.hasExplicitSecret {
|
||
updates["secret_digest"] = entry.explicitDigest
|
||
updates["secret_ciphertext"] = entry.explicitCipher
|
||
}
|
||
return tx.UpdatePeer([]int64{*existing.Id}, updates)
|
||
}
|
||
|
||
name := entry.name
|
||
remark := entry.remark
|
||
authID := entry.createAuthID
|
||
digest := entry.createDigest
|
||
cipher := entry.createCipher
|
||
quota := entry.quota
|
||
expires := entry.expires
|
||
maxDevices := entry.maxDevices
|
||
disabled := entry.disabled
|
||
download := entry.source.DownloadBytes
|
||
upload := entry.source.UploadBytes
|
||
bannedUntil := entry.source.BannedUntil
|
||
lastConnection := entry.source.LastConnectionAt
|
||
|
||
peer := entity.Peer{
|
||
Name: &name,
|
||
Remark: &remark,
|
||
AuthId: &authID,
|
||
SecretDigest: &digest,
|
||
SecretEncrypted: &cipher,
|
||
QuotaBytes: "a,
|
||
DownloadBytes: &download,
|
||
UploadBytes: &upload,
|
||
ExpiresAt: &expires,
|
||
MaxDevices: &maxDevices,
|
||
Disabled: &disabled,
|
||
BannedUntil: &bannedUntil,
|
||
LastConnectionAt: &lastConnection,
|
||
}
|
||
_, saveErr := tx.SavePeer(peer)
|
||
return saveErr
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
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 {
|
||
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt})
|
||
}
|
||
|
||
func strVal(v *string) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
return *v
|
||
}
|
||
func int64Val(v *int64) int64 {
|
||
if v == nil {
|
||
return 0
|
||
}
|
||
return *v
|
||
}
|