Files
HY2XS_flamy/apps/service/peer.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

978 lines
46 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"
"time"
"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"
)
// PagePeer отдаёт страницу списка пиров и ЧЕСТНО сообщает, известна ли картина
// подключений.
//
// Что было: `onlineUsers, _ := Hysteria2Online()`. Отказ Traffic Stats API
// молча превращался в пустую карту, и оператор видел «все офлайн» вместо
// «состояние неизвестно». Признак возвращается один на страницу — см.
// vo.PeerPageVo.
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, string, error) {
peers, total, err := dao.PagePeer(peerPageDto)
if err != nil {
return nil, 0, "", err
}
onlineState := vo.PeerOnlineStateOk
onlineUsers, onlineErr := Hysteria2Online()
if onlineErr != nil {
// Отказ здесь не является отказом страницы: список пиров и их
// сохранённое состояние известны и полезны сами по себе. Неизвестной
// становится ровно одна колонка, и об этом сказано явно.
logrus.WithError(onlineErr).
Warn("список пиров: картина подключений недоступна, признак online не определён")
onlineState = vo.PeerOnlineStateUnavailable
onlineUsers = map[string]int64{}
}
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, onlineState, 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 := newPeerAuthID()
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 {
// Снимок «до» читается ОДИН раз и обслуживает обе задачи: защиту пира
// установщика и решение о том, нужен ли разрыв живой сессии. Раньше
// assertBootstrapPeerIdentityUnchanged читал одного и того же пира до двух
// раз подряд собственными запросами.
before, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := assertBootstrapPeerIdentityUnchanged(before, peerDto); err != nil {
return err
}
updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
}
if secret, requested := requestedSecret(peerDto.Secret); requested {
digest, err := PeerSecretDigest(secret)
if err != nil {
return err
}
enc, err := EncryptPeerSecret(secret)
if err != nil {
return err
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
// Новое поколение учётных данных получает новую идентичность живых
// сессий. Без этого сессия, установленная по уже отозванному секрету,
// оставалась неотличимой от законной: `auth_id` у неё прежний, пир в
// базе есть, доступ открыт — и cron не имел ни одного признака, по
// которому её следовало бы завершить. Разбор — в peer_secret.go.
//
// Разрыв при этом идёт по СТАРОМУ значению (см. ниже): именно им
// Hysteria знает сессию, которую отзывает оператор.
if credentialGenerationChanged(before.SecretDigest, digest) {
rotatedAuthID, err := newPeerAuthID()
if err != nil {
return err
}
updates["auth_id"] = rotatedAuthID
}
}
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
}
// Решение принимается ДО записи, а сам разрыв — строго ПОСЛЕ неё.
//
// Порядок обязателен и обратному не подлежит: сначала долговременная
// запись, потом разрыв. При обратном порядке клиент успевает
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
// пиром, чьё состояние уже изменено.
needsReconcile := updateRequiresReconcile(before, peerDto, time.Now().UnixMilli())
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
return err
}
if !needsReconcile {
return nil
}
// Ровно ОДИН `/kick` при любом сочетании изменений: ротация секрета вместе
// с отключением и урезанной квотой — это по-прежнему одна операция над
// одним пиром.
return reconcileLiveSessions([]string{authIDOf(before)})
}
// updateRequiresReconcile отвечает, делает ли правка живую сессию устаревшей.
//
// Что было. Разрыв выполнялся при одном-единственном условии:
//
// peerDto.Disabled != nil && *peerDto.Disabled == 1
//
// Отключение пира действительно было первым, что починили, но оно не
// единственный способ отозвать доступ через форму. Мимо проверки проходили:
//
// смена секрета — старые учётные данные недействительны, а сессия,
// установленная по ним, продолжает работать;
// урезание квоты — «100 ГБ -> 5 ГБ» при израсходованных 10 ГБ;
// перенос срока — «истекает завтра» -> «истёк вчера»;
// снижение лимита — «5 устройств -> 1» при пяти подключённых.
//
// Во всех четырёх случаях панель показывала новое состояние, а пир продолжал
// пользоваться доступом по старому — то есть ровно тот же дефект, что и в
// UX-06, только под другими именами полей.
//
// Правило асимметрично намеренно: ограничение применяется немедленно,
// послабление — нет. Увеличенная квота, продлённый срок, поднятый лимит
// устройств и правка имени или пометки сессию не рвут: у оператора нет
// причины ронять работающее соединение, расширяя пиру права.
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
// Смена секрета. Учётные данные, по которым сессия была установлена, с
// этого момента недействительны — держать её открытой нечем.
//
// Условие смотрит на ЗАПРОС, а не на смену поколения credentials: повтор
// того же секрета после неудавшегося разрыва обязан дать вторую попытку,
// ровно как повторное «Отключить». Ротация `auth_id` при этом не
// выполняется — см. credentialGenerationChanged.
if _, requested := requestedSecret(peerDto.Secret); requested {
return true
}
// Запрошенное `disabled=1`, а не переход из включённого состояния.
//
// Так операция остаётся повторяемой: если разрыв не удался, оператор
// повторяет «Отключить» и получает вторую попытку, вместо того чтобы
// включать пира ради возможности отключить его снова.
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
return true
}
// Снижение лимита устройств.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/kick` оперирует
// идентификатором клиента, а не конкретным экземпляром подключения. После
// переподключения новый admission limit пропустит ровно столько
// устройств, сколько разрешено теперь.
if peerDto.MaxDevices != nil && before.MaxDevices != nil &&
*peerDto.MaxDevices < *before.MaxDevices {
return true
}
// Квота и срок: разрыв нужен, только если значение ДЕЙСТВИТЕЛЬНО менялось
// и новое значение уже закрывает доступ. Проверка идёт через ту же
// peerAccessDenied, что и авторизация, поэтому «закрывает доступ» здесь и
// «не пустит при следующем подключении» — буквально одно условие.
after := before
changed := false
if peerDto.QuotaBytes != nil && (before.QuotaBytes == nil || *peerDto.QuotaBytes != *before.QuotaBytes) {
quota := *peerDto.QuotaBytes
after.QuotaBytes = &quota
changed = true
}
if peerDto.ExpiresAt != nil && (before.ExpiresAt == nil || *peerDto.ExpiresAt != *before.ExpiresAt) {
expires := *peerDto.ExpiresAt
after.ExpiresAt = &expires
changed = true
}
if !changed {
return false
}
// Пир, которому доступ был закрыт и до правки, отдельного разрыва не
// требует: его сессию уже завершил тот, кто закрыл доступ.
return peerAccessDenied(after, now) && !peerAccessDenied(before, now)
}
// reconcileLiveSessions приводит живые сессии Hysteria в соответствие с уже
// СОХРАНЁННЫМ состоянием.
//
// Единственный путь для всех операций, способных сделать живую сессию
// устаревшей: отключение пира, временная блокировка, смена секрета, урезание
// квоты и срока, снижение лимита устройств, импорт партии и удаление пира.
// Отдельных методов kick для каждой из них нет намеренно — иначе «применили
// изменение, но забыли завершить сессию» появлялось бы заново с каждой новой
// операцией, ровно так, как это уже случилось с удалением и импортом.
//
// Вызывается СТРОГО после того, как долговременное состояние записано.
// Обратный порядок оставляет клиенту окно между `/kick` и записью, в котором
// он успевает переподключиться и остаётся на связи с уже изменённым пиром.
//
// Отказ НЕ откатывает состояние: безопасная половина операции достигнута, и
// возвращать доступ из-за неудачи второго шага нельзя. Вызывающему
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
func reconcileLiveSessions(authIDs []string) error {
if err := disconnectAuthIDs(authIDs); err != nil {
logrus.WithError(err).
WithField("authIds", len(authIDs)).
Error("peer state persisted, but hysteria2 session disconnect failed")
return PeerDisconnectError()
}
return nil
}
// authIDOf возвращает идентификатор, которым Hysteria знает пира, или пустую
// строку. Пира без authId Hysteria не знает: рвать нечего, и это не отказ.
func authIDOf(peer entity.Peer) string {
if peer.AuthId == nil {
return ""
}
return *peer.AuthId
}
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
//
// Работает по УЖЕ ПРОЧИТАННОМУ снимку пира: раньше функция делала до двух
// собственных запросов за ту же строку, которую вызывающий читает и сам.
func assertBootstrapPeerIdentityUnchanged(existing entity.Peer, peerDto dto.PeerUpdateDto) error {
isBootstrap := existing.Name != nil && *existing.Name == ReservedBootstrapPeerName
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
// существует.
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName && !isBootstrap {
return ErrPeerNameReserved
}
if !isBootstrap {
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).
//
// Порядок шагов — не стилистика, а единственный, который не оставляет пиру
// доступ.
//
// Что было: `return dao.DeletePeer([]int64{id})`. Строка исчезала, живая
// QUIC-сессия оставалась, и — хуже того — вместе со строкой исчезал `auth_id`,
// то есть единственное, чем эту сессию можно было завершить. Состояние
// становилось невосстановимым: удалённый пир пользовался доступом, пока не
// переподключался по своей воле, и сделать с этим было уже нечего.
//
// Теперь:
//
// 1. прочитать пира и запомнить его auth ID;
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
// 3. завершить живые сессии по запомненному auth ID;
// 4. удалить строку.
//
// Ключевые исходы:
//
// запись не удалась -> строка не изменена, удаления не было;
// разрыв не удался -> строка остаётся с disabled=1, новые подключения
// запрещены, оператор повторяет удаление;
// разрыв прошёл, а
// удаление не удалось -> строка остаётся отключённой, сессия уже завершена.
//
// Ни один из них не возвращает пиру доступ, и отката после `/kick` нет
// намеренно: снимать достигнутое безопасное состояние из-за неудачи
// последнего шага нельзя.
//
// Запись идёт через dao, а не через сервисный UpdatePeer: guard пира
// установщика запрещает менять его ИДЕНТИЧНОСТЬ, а не отключать его, и
// пропускать законное удаление через проверку смены имени и секрета незачем.
func DeletePeer(id int64) error {
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"disabled": int64(1)}); err != nil {
return err
}
if err := reconcileLiveSessions([]string{authIDOf(peer)}); err != nil {
return err
}
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 {
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
return err
}
return reconcileLiveSessions([]string{authIDOf(peer)})
}
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 := newPeerAuthID()
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).
//
// Четвёртый проход появился позже трёх: живые сессии.
//
// Импорт — это bulk state replacement, а не правка пометки: он переписывает
// credential- и access-состояние существующего пира целиком, включая
// `auth_id`, `secret_digest`, `quota_bytes`, `expires_at` и `disabled`. Сессии
// при этом не трогались вовсе, поэтому пир, отключённый импортом или
// получивший новый секрет, продолжал пользоваться доступом по старому.
//
// Старые auth ID собираются ВНУТРИ транзакции и разрываются ПОСЛЕ commit.
// Оба слова важны:
//
// внутри — потому что после commit старого значения в базе уже нет:
// `auth_id` перезаписан значением из файла;
// после — потому что `/kick` до commit оставляет клиенту окно, в котором он
// переподключается к ещё не изменённому пиру.
//
// Разрываются сессии ВСЕХ существующих записей партии, а не тех, у кого
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
// по семи access-полям:
//
// 1. импорт и так переписывает состояние целиком;
// 2. старый auth ID гарантированно нужен при его замене;
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
// попытаться завершить старые сессии;
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
// access-changing» — то есть второго места, где политика может разойтись
// с peerAccessDenied.
//
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
// Для административной операции переноса это нормальная цена.
//
// Новые пиры не разрываются: до импорта их живых сессий существовать не могло.
func UpsertPeerExport(items []bo.PeerExport) error {
if err := ValidatePeerImportBatch(items); err != nil {
return err
}
prepared, err := preparePeerImport(items)
if err != nil {
return err
}
var replacedAuthIDs []string
if err := dao.WithPeerTx(func(tx dao.PeerTx) error {
// Список собирается заново на каждой попытке: WithPeerTx может
// вызвать функцию повторно, и накопленный от прошлого прохода хвост
// означал бы разрыв сессий, которых партия не касалась.
replacedAuthIDs = replacedAuthIDs[:0]
for _, entry := range prepared {
replaced, err := applyPeerImportEntry(tx, entry)
if err != nil {
return err
}
if replaced != "" {
replacedAuthIDs = append(replacedAuthIDs, replaced)
}
}
return nil
}); err != nil {
// Транзакция откачена: состояние не менялось, разрывать нечего.
return err
}
return reconcileLiveSessions(replacedAuthIDs)
}
// applyPeerImportEntry применяет одну запись и возвращает auth ID, который
// Hysteria знала ДО применения, — пустую строку для вновь созданной записи.
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, 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
}
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
// файла, а Hysteria знает пира по прежнему.
replaced := authIDOf(existing)
// Импорт — вторая дверь к смене учётных данных, и правило поколения у
// неё то же самое: новый секрет получает новую идентичность сессий.
//
// Дверь эта закрывалась отдельно, потому что здесь состояние приходит
// из файла. Случай, который проходил мимо: запись найдена по ИМЕНИ либо
// несёт прежний `auth_id`, а секрет в файле новый. Тогда `auth_id`
// оставался прежним, и сессия, установленная по отозванному секрету,
// снова выглядела законной — тот же дефект, что и в UpdatePeer.
//
// Значение `auth_id` из файла при этом перебивается, и это осознанно:
// в импорте оно является КЛЮЧОМ ПОИСКА и переносимым состоянием, а не
// обещанием сохранить идентичность отозванного поколения. Если файл
// несёт ДРУГОЙ `auth_id`, ротация не нужна — идентичность уже сменилась
// им самим, и старая сессия становится orphan без нашего участия.
if entry.hasExplicitSecret &&
credentialGenerationChanged(existing.SecretDigest, entry.explicitDigest) &&
resultingAuthID(entry, replaced) == replaced {
rotated, err := newPeerAuthID()
if err != nil {
return "", err
}
updates["auth_id"] = rotated
}
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
return "", err
}
return replaced, nil
}
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
}
// resultingAuthID — значение, которое окажется в строке после применения
// записи импорта, если ротации не будет.
//
// Файл волен не нести `auth_id` вовсе: тогда поиск шёл по имени, и прежнее
// значение остаётся.
func resultingAuthID(entry preparedPeerImport, existing string) string {
if entry.authID != "" {
return entry.authID
}
return existing
}
// 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
}