Files
HY2XS_flamy/apps/service/peer.go
T
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00

706 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"fmt"
"strings"
"github.com/sirupsen/logrus"
"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 = &PeerError{
Code: constant.ErrCodePeerBootstrapLocked,
Message: fmt.Sprintf(
"пир %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{}, ErrPeerNameRequired
}
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, ErrPeerNameReserved
}
taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil {
return vo.PeerVo{}, err
}
if taken {
return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
}
secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
if err != nil {
return vo.PeerVo{}, err
}
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
}
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
return err
}
// Отключение пира — это ОБЕ половины официального контракта Hysteria.
//
// Запись `disabled=1` закрывает только будущие обращения к HTTP-auth: её
// видит условие выборки в Hysteria2Auth. Уже установленная QUIC-сессия
// живёт своей жизнью и сама по себе не разрывается — то есть после
// «Отключить» пир продолжал пользоваться доступом сколько угодно долго,
// пока не переподключался по своей воле. Панель при этом показывала его
// отключённым.
//
// Вторую половину даёт Traffic Stats `/kick`. Официальная документация
// описывает их именно как пару: `/kick` завершает сессию, но клиент
// немедленно переподключается, поэтому одновременно требуется блокировка в
// auth backend. По отдельности не работает ни одна.
//
// Порядок обязателен и обратному не подлежит: сначала долговременная
// запись, потом разрыв. При обратном порядке клиент успевает
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
// формально отключённым пиром.
//
// Условие проверяет ЗАПРОШЕННОЕ состояние, а не переход из включённого.
// Так операция остаётся повторяемой: если разрыв не удался, оператор
// повторяет «Отключить» и получает вторую попытку, вместо того чтобы
// включать пира ради возможности отключить его снова.
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
return disconnectAfterRevoke(id)
}
return nil
}
// disconnectAfterRevoke рвёт сессии пира после уже применённой записи.
//
// Отказ НЕ откатывает состояние: безопасная его половина достигнута, и
// возвращать пиру полный доступ из-за неудачи второго шага нельзя. Вызывающему
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
func disconnectAfterRevoke(id int64) error {
if err := DisconnectPeers([]int64{id}); err != nil {
logrus.WithError(err).
WithField("peerId", id).
Error("peer access revoked in database, but hysteria2 session disconnect failed")
return PeerDisconnectError()
}
return nil
}
// 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 ErrPeerNameReserved
}
}
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/admin/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})
}
// KickPeer — временная блокировка: пир не пускается до bannedUntil, а его
// текущая сессия завершается.
//
// Механизм независим от `disabled`. Это два разных решения оператора с разным
// сроком жизни: `banned_until` истекает сам, `disabled` снимается только
// руками. Поэтому ReleaseKickPeer не трогает `disabled`, а включение пира не
// сбрасывает `banned_until`.
//
// Что было. `banned_until` записывался здесь, а затем ВТОРОЙ РАЗ внутри
// Hysteria2Kick — то же значение двумя операторами подряд. Хуже дублирования
// был порядок отказа: Hysteria2Kick начинался с проверки состояния службы и
// возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
// применилась. Операция отвечала чистым отказом, находясь при этом в
// применённом состоянии, и оператор видел «не сработало» у сработавшей
// блокировки.
func KickPeer(id int64, bannedUntil int64) error {
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
return err
}
return disconnectAfterRevoke(id)
}
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 {
// Генерация одна на весь продукт: импорт без секрета обязан давать
// пира, неотличимого от созданного через форму.
createSecret, err := GeneratePeerSecret(name)
if err != nil {
return nil, err
}
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: &quota,
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
}