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
+125 -3
View File
@@ -63,7 +63,127 @@ Hysteria-интеграции с официальной документацие
задокументирован в
[docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md).
### Исправлено — отзыв доступа к VPN
Одиннадцатый проход — второй разбор того же слоя, уже по состоянию после
десятого. Тема: границы между частями access-control. Десятый проход починил
одну операцию отзыва доступа и оставил остальные — удаление, импорт, смену
секрета, урезание квоты и срока, снижение лимита устройств — в прежнем
состоянии; правило доступа при этом продолжало существовать в двух
экземплярах, написанных разными SQL-условиями, которые расходились ровно на
границах. Проведены три границы: `состояние пира → решение о доступе`,
`сохранённое изменение → живая сессия`, `планировщик → полностью
принадлежащая ему работа`.
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
существовало в двух экземплярах: SQL-условием внутри `Hysteria2Auth` и
другим SQL-условием внутри cron. Второе не было отрицанием первого, и
расхождение приходилось на границы — `quota = 0`, `usage = quota`,
`now = expiresAt`, `now = bannedUntil`: авторизация отказывала, cron сессию
не рвал. Условие cron требовало СТРОГОГО превышения квоты, а счётчики растут
порциями по ответу Traffic Stats API, поэтому точное равенство — обычный
исход очередного сбора. Пир с исчерпанной квотой не пускался заново, но его
живая сессия не разрывалась никогда.
Политика вынесена в одну функцию `peerAccessDenied`; авторизация ищет пира
только по `secret_digest`, cron применяет ту же функцию. `quota = -1`
единственный способ снять ограничение, `quota = 0` означает ноль байтов,
`usage = quota` означает исчерпанный лимит, `bannedUntil = now` означает
закончившуюся блокировку. Строка без решающего поля трактуется как
повреждённая и ведёт к отказу.
### Исправлено — операции, оставляющие живую сессию
- **Удаление пира не отзывало доступ и теряло `authId`.** `DeletePeer` состоял
из одного `dao.DeletePeer`: строка исчезала, живая QUIC-сессия оставалась, а
вместе со строкой исчезал `auth_id` — единственное, чем эту сессию можно было
бы завершить. Состояние становилось невосстановимым. Теперь: прочитать пира и
запомнить `authId` → записать `disabled=1``/kick` → удалить строку. При
неудаче разрыва строка остаётся отключённой, и оператор повторяет удаление.
- **Разрыв выполнялся только при `disabled=1`.** Мимо проходили смена секрета,
урезание квоты ниже израсходованного, перенос срока в прошлое и снижение
лимита устройств — каждая из них закрывает доступ, но сессию не трогала.
Правило асимметрично: ограничение применяется немедленно, послабление — нет.
При любом сочетании изменений уходит ровно один `/kick`.
- **Импорт не завершал сессии переписанных пиров.** Импорт переписывает
`auth_id`, секрет, квоту, срок и `disabled` целиком. Старые `authId`
собираются внутри транзакции — после commit их в базе уже нет, — а разрыв
идёт после commit: до него клиент успел бы переподключиться к ещё не
изменённому пиру.
- **Единственный вход к `/kick`.** Все операции идут через один
`reconcileLiveSessions`, а он — через `disconnectAuthIDs`, который принимает
готовые идентификаторы, дедуплицирует их, разбивает на части и не обращается
к базе вовсе. Пока обращений к `/kick` было два, они расходились: у cron не
было ни дедупликации, ни разбиения, зато был POST с пустым массивом каждые 30
секунд.
- **Формулировка частичного результата больше не называет операцию.** Через
`peer_disconnect_failed` отчитываются восемь операций; прежнее «новые
подключения пира запрещены» было верно ровно для отключения пира, а для
удалённого — бессмысленно. Контроллеры удаления и импорта переведены на
`failService`, панель разбирает исход импорта и обновляет список при любом
результате.
### Исправлено — цикл учёта
- **Джоба убегала из жизненного цикла планировщика.** `CronHandleAccount`
запускала горутину, которая запускала ещё две. Для планировщика джоба
заканчивалась почти мгновенно, поэтому `StopCron()` не ждал настоящей работы:
`releaseResource()` закрывал SQLite, а горутины продолжали в неё писать.
Параллельность обеих половин означала ещё и то, что принудительное отключение
читало счётчики до записи снятой дельты. Теперь джоба синхронна, под одним
мьютексом на весь цикл, и порядок строгий: сбор трафика, затем enforcement.
- **Три nil-разыменования роняли процесс целиком.** `*trafficSecretConfig.Value`,
`*item.AuthId` в принудительном отключении и `*item.Id` в сбросе трафика — все
внутри горутин, где их некому перехватить, то есть каждое означало падение
сервиса вместе с обработчиком machine-auth.
- **Гейт `Hysteria2IsRunning` удалён из cron.** `util.Exec` не отличает «служба
неактивна» от «спросить не удалось», поэтому сломанный `systemctl` при живой
Hysteria молча отключал и учёт трафика, и принудительное отключение — без
единой строки в журнале.
- **Потеря дельты трафика больше не молчит.** `GET /traffic?clear=1`
деструктивен: счётчики Hysteria обнуляются сразу после отправки ответа.
Прежний код на отказе записи делал `continue`, и дельта исчезала, не оставив
следа в исходе джобы. Полное решение требует смены модели учёта
(недеструктивное чтение плюс долговременные checkpoint'ы) и в `1.0.0`
намеренно не вводится: квота — операционный предел доступа, а не учёт с
финансово значимым каждым байтом.
### Исправлено — лимит устройств под нагрузкой
- **Параллельные подключения превышали `maxDevices`.** Между чтением `/online`
и ответом «allow» место ничем не удерживалось: при `online = max-1` два
одновременных запроса получали разрешение оба. Мьютекс вокруг `/online` этого
не чинит — ответив «allow», админка не создаёт подключение, и следующий
запрос продолжает видеть прежнее число. Появился process-local учёт выданных,
но ещё не проявившихся разрешений: решение принимается по сумме «подключено
плюс зарезервировано», рост `online` снимает соответствующее число
резерваций, протухшие снимаются по внутреннему TTL. Ни Redis, ни таблиц в
базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере.
### Исправлено — гейты сборки
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не
возвращает успех из ветки ошибки» была записана регуляркой
`err != nil \{[\s\S]*?return \*peer\.Id`, а ленивый `[\s\S]*?` свободно
пересекает границы блоков: она давала совпадение на любой функции, где после
какой-нибудь проверки ошибки ниже стоит успешный возврат. Проверено на коде
из `HEAD` — гейт нельзя было удовлетворить, не сломав продукт. Тело ветки
теперь выделяется по балансу фигурных скобок.
- **Детектор гонок стал обязательным шагом сборки.** Состояние трекера
разрешений и мьютекс цикла учёта принадлежат процессу, поэтому их
корректность не наблюдаема ни в `go test`, ни в `go vet`. Пропуск при
недоступном C-компиляторе не предусмотрен: сборка, молча пропускающая
проверку, выдаёт внешне неотличимый production-артефакт.
### Исправлено — отзыв доступа к VPN (десятый проход)
- **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только
выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к
@@ -74,11 +194,13 @@ Hysteria-интеграции с официальной документацие
блокировку в auth backend как пару: по отдельности не работает ни одна
половина.
Появился `service.DisconnectPeers` — только официальный Traffic Stats
Появился отдельный примитив разрыва — только официальный Traffic Stats
`/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом
проставлял `banned_until`, поэтому воспользоваться им для отключения было
нельзя: операция записала бы заодно временную блокировку — другой механизм с
другим сроком жизни.
другим сроком жизни. (В одиннадцатом проходе он принимает готовые `authId`, а
не идентификаторы пиров: удалению и импорту старое значение нужно уже после
его исчезновения из базы.)
Порядок обратному не подлежит: сначала долговременная запись, затем разрыв.
При обратном клиент успевает переподключиться в окне между `/kick` и записью.