162759c599
Разбор кода на 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.
706 lines
29 KiB
Go
706 lines
29 KiB
Go
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: "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
|
||
}
|