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
+345
View File
@@ -0,0 +1,345 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Лимит устройств проверяется на ПАРАЛЛЕЛЬНЫХ запросах авторизации.
//
// Прежняя проверка сравнивала ответ `/online` с maxDevices и сразу отвечала
// «allow»: между чтением и ответом место ничем не удерживалось, поэтому два
// одновременных подключения при `online = max-1` получали разрешение оба и
// объявленный лимит превышался.
//
// Последовательный тест этого не поймает никогда — нужен барьер, на котором
// оба запроса гарантированно видят ОДНО И ТО ЖЕ состояние Hysteria.
// --- Барьерный тест против настоящего HTTP -----------------------------------
// startBarrierTrafficStats поднимает Traffic Stats API, который задерживает
// первые `hold` обращений к `/online` до тех пор, пока не придут все.
//
// Так воспроизводится ровно то состояние гонки, которое случается на живом
// сервере: оба запроса авторизации прочитали статистику до того, как хоть один
// из них успел превратиться в подключение.
func startBarrierTrafficStats(t *testing.T, online map[string]int64, hold int) {
t.Helper()
var mu sync.Mutex
arrived := 0
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/online":
mu.Lock()
arrived++
last := arrived == hold
mu.Unlock()
if last {
close(release)
} else {
select {
case <-release:
case <-time.After(5 * time.Second):
// Барьер не собрался — отпускаем, чтобы тест упал по
// существу, а не по таймауту всего прогона.
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(online)
case "/kick":
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
pointHysteriaConfigAt(t, server.URL)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
}
// Главная регрессия AUTH-03: при `online = max-1` разрешение обязан получить
// ровно ОДИН из двух одновременных запросов.
func TestHysteria2AuthHoldsDeviceLimitUnderConcurrency(t *testing.T) {
newTestDB(t)
// seedPeer создаёт пира с maxDevices = 3, поэтому online = 2 — это
// последнее свободное место.
startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 2}, 2)
seedPeer(t, "alpha1", "alpha-auth-id")
var wg sync.WaitGroup
results := make([]error, 2)
for i := range results {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, _, err := Hysteria2Auth("alpha1-secret")
results[idx] = err
}(i)
}
wg.Wait()
allowed := 0
for _, err := range results {
if err == nil {
allowed++
}
}
if allowed != 1 {
t.Fatalf("на последнее свободное место допущено %d подключений из 2", allowed)
}
}
// Свободных мест два — проходят оба: механизм не должен превращаться в
// сериализацию подключений.
func TestHysteria2AuthAdmitsBothWhenTwoSlotsFree(t *testing.T) {
newTestDB(t)
startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 1}, 2)
seedPeer(t, "alpha1", "alpha-auth-id")
var wg sync.WaitGroup
results := make([]error, 2)
for i := range results {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, _, err := Hysteria2Auth("alpha1-secret")
results[idx] = err
}(i)
}
wg.Wait()
for idx, err := range results {
if err != nil {
t.Fatalf("подключение %d отклонено при двух свободных местах: %v", idx, err)
}
}
}
// Резервации принадлежат КОНКРЕТНОМУ пиру: занятое место одного не должно
// закрывать доступ другому.
func TestDeviceAdmissionsAreIsolatedPerPeer(t *testing.T) {
newTestDB(t)
startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 2, "bravo-auth-id": 0}, 2)
seedPeer(t, "alpha1", "alpha-auth-id")
seedPeer(t, "bravo2", "bravo-auth-id")
var wg sync.WaitGroup
var alphaErr, bravoErr error
wg.Add(2)
go func() {
defer wg.Done()
_, _, alphaErr = Hysteria2Auth("alpha1-secret")
}()
go func() {
defer wg.Done()
_, _, bravoErr = Hysteria2Auth("bravo2-secret")
}()
wg.Wait()
if alphaErr != nil {
t.Fatalf("первое подключение пира на последнее место отклонено: %v", alphaErr)
}
if bravoErr != nil {
t.Fatalf("резервация чужого пира закрыла доступ: %v", bravoErr)
}
}
// Последовательно тот же лимит тоже держится: второй запрос видит место,
// занятое первым, хотя `/online` ещё показывает прежнее число.
func TestHysteria2AuthCountsPendingAdmissionSequentially(t *testing.T) {
newTestDB(t)
// Число НЕ меняется между запросами — именно так и ведёт себя Hysteria,
// пока клиент ещё устанавливает соединение.
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
seedPeer(t, "alpha1", "alpha-auth-id")
// max = 3, online = 1 -> свободно два места.
if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil {
t.Fatalf("первое подключение отклонено: %v", err)
}
if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil {
t.Fatalf("второе подключение отклонено: %v", err)
}
// Третье превысило бы лимит: 1 онлайн + 2 выданных разрешения.
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("подключение сверх лимита принято: выданные разрешения не учтены")
}
}
// --- Единица учёта ------------------------------------------------------------
const admissionAuthID = "auth-under-test"
func TestReserveDeviceSlotAllowsUpToLimit(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 1; i <= 3; i++ {
if !reserveDeviceSlot(admissionAuthID, 0, 3, now) {
t.Fatalf("разрешение %d из 3 отклонено", i)
}
}
if reserveDeviceSlot(admissionAuthID, 0, 3, now) {
t.Fatal("выдано четвёртое разрешение при лимите 3")
}
}
// Рост числа онлайн-устройств означает, что выданные разрешения превратились в
// подключения. Не сняв их, админка посчитала бы одно устройство дважды, и
// лимит стал бы вдвое строже объявленного.
func TestReserveDeviceSlotAbsorbsMaterializedAdmissions(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 0, 2, now) {
t.Fatal("первое разрешение отклонено")
}
// Клиент подключился: Hysteria теперь видит одно устройство.
if !reserveDeviceSlot(admissionAuthID, 1, 2, now) {
t.Fatal("проявившееся разрешение посчитано дважды")
}
// Теперь занято: одно подключение плюс одно выданное разрешение.
if reserveDeviceSlot(admissionAuthID, 1, 2, now) {
t.Fatal("выдано разрешение сверх лимита")
}
}
// Разрешение, за которым не последовало подключения, освобождает место само.
func TestReserveDeviceSlotExpiresPendingAdmission(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 0, 1, now) {
t.Fatal("первое разрешение отклонено")
}
if reserveDeviceSlot(admissionAuthID, 0, 1, now) {
t.Fatal("выдано разрешение сверх лимита 1")
}
// Клиент так и не подключился.
later := now.Add(pendingAdmissionTTL + time.Second)
if !reserveDeviceSlot(admissionAuthID, 0, 1, later) {
t.Fatal("протухшее разрешение не освободило место")
}
}
// Место не занимается отказом: иначе серия отклонённых попыток удерживала бы
// слоты на всё время TTL.
func TestReserveDeviceSlotDoesNotConsumeSlotOnRefusal(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 0; i < 5; i++ {
if reserveDeviceSlot(admissionAuthID, 3, 3, now) {
t.Fatal("выдано разрешение при исчерпанном лимите")
}
}
// Одно устройство отключилось — место обязано быть свободно немедленно.
if !reserveDeviceSlot(admissionAuthID, 2, 3, now) {
t.Fatal("отклонённые попытки заняли места")
}
}
// admissionEntries — размер учёта.
func admissionEntries() int {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
return len(deviceAdmissions.byAuthID)
}
// Учёт не растёт от повторных обращений: одна запись на пира, сколько бы
// попыток он ни сделал.
//
// Границы роста здесь две, и обе существенны. Верхняя — число пиров: сюда
// попадают только authId, прошедшие поиск по secret_digest и всю политику
// доступа, поэтому произвольный ключ извне добавить нельзя. Нижняя — запись
// исчезает, как только помнить о пире нечего (см. следующий тест).
func TestReserveDeviceSlotKeepsOneEntryPerPeer(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 0; i < 100; i++ {
reserveDeviceSlot("transient-auth", 1, 1, now)
}
if got := admissionEntries(); got != 1 {
t.Fatalf("повторные попытки одного пира дали %d записей", got)
}
}
// Запись исчезает, когда о пире нечего помнить: он не онлайн и выданных
// разрешений за ним нет. Без этого карта накапливала бы по строке на каждый
// когда-либо авторизовавшийся authId, включая давно удалённых пиров.
func TestReserveDeviceSlotForgetsIdlePeer(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 1, 3, now) {
t.Fatal("разрешение отклонено при свободном месте")
}
if admissionEntries() != 1 {
t.Fatal("учёт не запомнил выданное разрешение")
}
// Пир отключился целиком, а выданное разрешение протухло: помнить нечего.
later := now.Add(pendingAdmissionTTL + time.Second)
// Лимит 0 — разрешение не выдаётся, поэтому запись остаться не должна.
reserveDeviceSlot(admissionAuthID, 0, 0, later)
if got := admissionEntries(); got != 0 {
t.Fatalf("запись о неактивном пире осталась: записей %d", got)
}
}
// Учёт выдерживает параллельный доступ и не выдаёт больше мест, чем есть.
// Проверка ловит дефект и без детектора гонок: он виден по числу разрешений.
func TestReserveDeviceSlotIsConcurrencySafe(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
const workers = 50
const limit = int64(7)
now := time.Now()
var wg sync.WaitGroup
var mu sync.Mutex
allowed := 0
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if reserveDeviceSlot(admissionAuthID, 0, limit, now) {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
if int64(allowed) != limit {
t.Fatalf("выдано %d разрешений при лимите %d", allowed, limit)
}
}