cb20d8d28f
Отзыв секрета не сходился: `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
978 lines
46 KiB
Go
978 lines
46 KiB
Go
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 = "a
|
||
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: "a,
|
||
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
|
||
}
|