fix(admin): свести access-control к одному правилу и одному пути отзыва

Второй разбор того же слоя, уже по состоянию после 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: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
This commit is contained in:
2026-09-01 20:46:21 +05:00
parent 162759c599
commit 6d1686b2be
25 changed files with 3675 additions and 244 deletions
+248 -52
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/sirupsen/logrus"
@@ -127,7 +128,16 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
}
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
// Снимок «до» читается ОДИН раз и обслуживает обе задачи: защиту пира
// установщика и решение о том, нужен ли разрыв живой сессии. Раньше
// assertBootstrapPeerIdentityUnchanged читал одного и того же пира до двух
// раз подряд собственными запросами.
before, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := assertBootstrapPeerIdentityUnchanged(before, peerDto); err != nil {
return err
}
@@ -162,75 +172,154 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if peerDto.Remark != nil {
updates["remark"] = *peerDto.Remark
}
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
return err
}
// Отключение пира — это ОБЕ половины официального контракта Hysteria.
//
// Запись `disabled=1` закрывает только будущие обращения к HTTP-auth: её
// видит условие выборки в Hysteria2Auth. Уже установленная QUIC-сессия
// живёт своей жизнью и сама по себе не разрывается — то есть после
// «Отключить» пир продолжал пользоваться доступом сколько угодно долго,
// пока не переподключался по своей воле. Панель при этом показывала его
// отключённым.
//
// Вторую половину даёт Traffic Stats `/kick`. Официальная документация
// описывает их именно как пару: `/kick` завершает сессию, но клиент
// немедленно переподключается, поэтому одновременно требуется блокировка в
// auth backend. По отдельности не работает ни одна.
// Решение принимается ДО записи, а сам разрыв — строго ПОСЛЕ неё.
//
// Порядок обязателен и обратному не подлежит: сначала долговременная
// запись, потом разрыв. При обратном порядке клиент успевает
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
// формально отключённым пиром.
// пиром, чьё состояние уже изменено.
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 disconnectAfterRevoke(id)
return true
}
return nil
// Снижение лимита устройств.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/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)
}
// disconnectAfterRevoke рвёт сессии пира после уже применённой записи.
// reconcileLiveSessions приводит живые сессии Hysteria в соответствие с уже
// СОХРАНЁННЫМ состоянием.
//
// Отказ НЕ откатывает состояние: безопасная его половина достигнута, и
// возвращать пиру полный доступ из-за неудачи второго шага нельзя. Вызывающему
// Единственный путь для всех операций, способных сделать живую сессию
// устаревшей: отключение пира, временная блокировка, смена секрета, урезание
// квоты и срока, снижение лимита устройств, импорт партии и удаление пира.
// Отдельных методов kick для каждой из них нет намеренно — иначе «применили
// изменение, но забыли завершить сессию» появлялось бы заново с каждой новой
// операцией, ровно так, как это уже случилось с удалением и импортом.
//
// Вызывается СТРОГО после того, как долговременное состояние записано.
// Обратный порядок оставляет клиенту окно между `/kick` и записью, в котором
// он успевает переподключиться и остаётся на связи с уже изменённым пиром.
//
// Отказ НЕ откатывает состояние: безопасная половина операции достигнута, и
// возвращать доступ из-за неудачи второго шага нельзя. Вызывающему
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
func disconnectAfterRevoke(id int64) error {
if err := DisconnectPeers([]int64{id}); err != nil {
func reconcileLiveSessions(authIDs []string) error {
if err := disconnectAuthIDs(authIDs); err != nil {
logrus.WithError(err).
WithField("peerId", id).
Error("peer access revoked in database, but hysteria2 session disconnect failed")
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(id int64, peerDto dto.PeerUpdateDto) error {
//
// Работает по УЖЕ ПРОЧИТАННОМУ снимку пира: раньше функция делала до двух
// собственных запросов за ту же строку, которую вызывающий читает и сам.
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 {
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return ErrPeerNameReserved
}
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName && !isBootstrap {
return ErrPeerNameReserved
}
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
if !isBootstrap {
return nil
}
@@ -263,7 +352,54 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/admin/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
//
// Порядок шагов — не стилистика, а единственный, который не оставляет пиру
// доступ.
//
// Что было: `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)
@@ -309,10 +445,14 @@ func ReleaseKickPeer(id int64) error {
// применённом состоянии, и оператор видел «не сработало» у сработавшей
// блокировки.
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 disconnectAfterRevoke(id)
return reconcileLiveSessions([]string{authIDOf(peer)})
}
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
@@ -534,6 +674,39 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
// Конфликт не гипотетический: пусть в базе есть 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
@@ -544,26 +717,42 @@ func UpsertPeerExport(items []bo.PeerExport) error {
return err
}
return dao.WithPeerTx(func(tx dao.PeerTx) error {
var replacedAuthIDs []string
if err := dao.WithPeerTx(func(tx dao.PeerTx) error {
// Список собирается заново на каждой попытке: WithPeerTx может
// вызвать функцию повторно, и накопленный от прошлого прохода хвост
// означал бы разрыв сессий, которых партия не касалась.
replacedAuthIDs = replacedAuthIDs[:0]
for _, entry := range prepared {
if err := applyPeerImportEntry(tx, entry); err != nil {
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)
}
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
// applyPeerImportEntry применяет одну запись и возвращает auth ID, который
// Hysteria знала ДО применения, — пустую строку для вновь созданной записи.
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, error) {
existing, found, err := findPeerForImport(tx, entry)
if err != nil {
return err
return "", err
}
// Пир установщика не переопределяется импортом ни при каком совпадении:
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
return fmt.Errorf(
return "", fmt.Errorf(
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
ReservedBootstrapPeerName,
)
@@ -589,7 +778,13 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
updates["secret_digest"] = entry.explicitDigest
updates["secret_ciphertext"] = entry.explicitCipher
}
return tx.UpdatePeer([]int64{*existing.Id}, updates)
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
// файла, а Hysteria знает пира по прежнему.
replaced := authIDOf(existing)
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
return "", err
}
return replaced, nil
}
name := entry.name
@@ -621,8 +816,9 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
// Вновь созданная запись: живой сессии до импорта существовать не могло.
_, saveErr := tx.SavePeer(peer)
return saveErr
return "", saveErr
}
// findPeerForImport ищет запись, которую импорт должен обновить.