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:
@@ -7,6 +7,7 @@ import (
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/proxy"
|
||||
"hy2xs-admin/util"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -62,15 +63,20 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
||||
if digestErr != nil {
|
||||
return 0, "", digestErr
|
||||
}
|
||||
peer, err := dao.GetPeer(`secret_digest = ?
|
||||
and disabled = 0
|
||||
and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
|
||||
and (expires_at = 0 or ? < expires_at)
|
||||
and ? > banned_until`, secretDigest, now, now)
|
||||
// Поиск идёт ТОЛЬКО по учётным данным. Политика доступа больше не живёт
|
||||
// внутри выборки: её объявляет peerAccessDenied, и ровно её же применяет
|
||||
// принудительное отключение в cron. Пока правило было записано двумя
|
||||
// разными SQL-условиями, авторизация и enforcement расходились на границах
|
||||
// quota, expiry и ban — см. комментарий в peer_access.go.
|
||||
peer, err := dao.GetPeer("secret_digest = ?", secretDigest)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
if peerAccessDenied(peer, now) {
|
||||
return 0, "", errors.New("peer access denied")
|
||||
}
|
||||
|
||||
// Строка без идентичности — повреждённые данные, а не пир.
|
||||
//
|
||||
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
|
||||
@@ -126,7 +132,15 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
||||
return 0, "", errors.New("device limit unavailable")
|
||||
}
|
||||
|
||||
if device, exist := onlineUsers[*peer.AuthId]; exist && *peer.MaxDevices <= device {
|
||||
// Место занимается ПОСЛЕ всех остальных проверок и с учётом уже выданных,
|
||||
// но ещё не проявившихся разрешений — см. peer_admission.go. Сравнение
|
||||
// одного лишь ответа `/online` пропускало параллельные подключения: между
|
||||
// чтением и ответом «allow» ничего не удерживало место, и два одновременных
|
||||
// запроса при `online=2, max=3` получали разрешение оба.
|
||||
//
|
||||
// Порядок существенен: если бы резервация делалась раньше проверки
|
||||
// квоты или срока, отказ по ним съедал бы слот на всё время TTL.
|
||||
if !reserveDeviceSlot(*peer.AuthId, onlineUsers[*peer.AuthId], *peer.MaxDevices, time.Now()) {
|
||||
return 0, "", errors.New("device limited")
|
||||
}
|
||||
|
||||
@@ -185,43 +199,54 @@ func hysteria2TrafficSecret() (string, error) {
|
||||
return *trafficSecretConfig.Value, nil
|
||||
}
|
||||
|
||||
// DisconnectPeers завершает активные Hysteria-сессии пиров и НИЧЕГО не пишет в
|
||||
// базу.
|
||||
// kickChunkSize ограничивает размер одного обращения к `/kick`.
|
||||
//
|
||||
// Разрыв сессии и запись состояния разделены сознательно. Прежний
|
||||
// Hysteria2Kick делал и то и другое: вместе с обращением к `/kick` он
|
||||
// проставлял `banned_until`. Из-за этого им нельзя было воспользоваться для
|
||||
// отключения пира — операция `disabled=1` записала бы заодно временную
|
||||
// блокировку, а это другой механизм с другим сроком жизни и другим способом
|
||||
// снятия. Единственный вызывающий (KickPeer) при этом писал `banned_until` ещё
|
||||
// и сам, то есть одно и то же значение уезжало в базу дважды.
|
||||
// Импорт применяет до MaxPeerImportItems записей за операцию, и без разбиения
|
||||
// в Hysteria уехал бы один POST с многотысячным массивом в теле. Значение
|
||||
// выбрано с запасом относительно любого реального размера панели: смысл здесь
|
||||
// не в оптимизации, а в отсутствии запроса, размер которого задаёт содержимое
|
||||
// пользовательского файла.
|
||||
const kickChunkSize = 100
|
||||
|
||||
// disconnectAuthIDs — ЕДИНСТВЕННЫЙ путь к Traffic Stats `/kick` в продукте.
|
||||
//
|
||||
// Здесь остаётся ровно официальный Traffic Stats `/kick` и ничего больше.
|
||||
// Контракт предельно узкий и намеренно ничего не знает про пиров:
|
||||
//
|
||||
// Состояние службы по systemd НЕ проверяется. Раньше путь начинался с
|
||||
// `!Hysteria2IsRunning() -> отказ`, и это давало худшее из двух: ответ systemd
|
||||
// не отличает «служба неактивна» от «спросить не удалось» (см.
|
||||
// Hysteria2IsRunning), поэтому сбой самого systemctl превращался в отказ
|
||||
// операции при живой Hysteria, а обратная ошибка молча пропускала бы разрыв.
|
||||
// auth IDs -> дедупликация -> порт API -> секрет -> POST /kick
|
||||
//
|
||||
// Никакой базы, никакого `disabled`, никакого `banned_until`. Разрыв сессии и
|
||||
// запись состояния разделены сознательно: прежний Hysteria2Kick делал и то и
|
||||
// другое — вместе с обращением к `/kick` он проставлял `banned_until`, — и
|
||||
// из-за этого им нельзя было воспользоваться для отключения пира: операция
|
||||
// записала бы заодно временную блокировку, а это другой механизм с другим
|
||||
// сроком жизни и другим способом снятия.
|
||||
//
|
||||
// Вход — именно auth IDs, а не идентификаторы пиров, и это не деталь. Операции
|
||||
// удаления и импорта меняют или убирают auth ID: после commit действующего
|
||||
// значения в базе уже нет, и рвать надо по тому, которое Hysteria знала ДО
|
||||
// операции. Функция, которая сама читала бы auth ID из базы, для этих двух
|
||||
// путей опоздала бы всегда.
|
||||
//
|
||||
// Состояние службы по systemd НЕ проверяется. Ответ systemd не отличает
|
||||
// «служба неактивна» от «спросить не удалось» (см. Hysteria2IsRunning),
|
||||
// поэтому сбой самого systemctl отказывал бы операции при живой Hysteria.
|
||||
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
|
||||
func DisconnectPeers(ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
peers, err := dao.ListPeer("id in ?", ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(peers))
|
||||
for _, item := range peers {
|
||||
if item.AuthId == nil || *item.AuthId == "" {
|
||||
func disconnectAuthIDs(authIDs []string) error {
|
||||
keys := make([]string, 0, len(authIDs))
|
||||
seen := make(map[string]struct{}, len(authIDs))
|
||||
for _, authID := range authIDs {
|
||||
if authID == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, *item.AuthId)
|
||||
if _, duplicate := seen[authID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[authID] = struct{}{}
|
||||
keys = append(keys, authID)
|
||||
}
|
||||
// Пир без authId Hysteria не знает: рвать нечего, и это не отказ.
|
||||
|
||||
// Ни одной цели — значит рвать нечего, и это не отказ. Раньше по
|
||||
// аналогичному пути в cron уезжал POST с пустым массивом каждые 30 секунд.
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -234,7 +259,14 @@ func DisconnectPeers(ids []int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proxy.NewHysteria2Api(apiPort).KickUsers(keys, secret)
|
||||
|
||||
api := proxy.NewHysteria2Api(apiPort)
|
||||
for _, chunk := range util.SplitArr(keys, kickChunkSize) {
|
||||
if err := api.KickUsers(chunk, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Hysteria2Url(accountId int64) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user