6d1686b2be
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
902 lines
41 KiB
Go
902 lines
41 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"
|
||
"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 {
|
||
// Снимок «до» читается ОДИН раз и обслуживает обе задачи: защиту пира
|
||
// установщика и решение о том, нужен ли разрыв живой сессии. Раньше
|
||
// 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 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
|
||
}
|
||
|
||
// Решение принимается ДО записи, а сам разрыв — строго ПОСЛЕ неё.
|
||
//
|
||
// Порядок обязателен и обратному не подлежит: сначала долговременная
|
||
// запись, потом разрыв. При обратном порядке клиент успевает
|
||
// переподключиться в окне между `/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 {
|
||
// Смена секрета. Учётные данные, по которым сессия была установлена, с
|
||
// этого момента недействительны — держать её открытой нечем.
|
||
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
|
||
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 := 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).
|
||
//
|
||
// Четвёртый проход появился позже трёх: живые сессии.
|
||
//
|
||
// Импорт — это 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)
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|