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
@@ -30,11 +30,23 @@ Hysteria-интеграции с официальной документацие
| CORE-01 | Ошибка называет `TCP port` для UDP-эндпоинта | P3 | закрыт |
| CORE-02 | `banned_until` писался дважды, отказ отчитывался как полный | P1 | закрыт |
| TYPE-01 | Типы полей журнала в панели расходились с сервером | P3 | закрыт |
| CORE-03 | Удаление пира не отзывало доступ и теряло `authId` | P0 | закрыт |
| CORE-04 | Разрыв сессии выполнялся только при `disabled=1` | P1 | закрыт |
| CORE-05 | Импорт не завершал сессии переписанных пиров | P1 | закрыт |
| CORE-06 | Джоба учёта убегала из жизненного цикла планировщика | P1 | закрыт |
| CORE-07 | Три nil-разыменования в cron роняли процесс целиком | P0 | закрыт |
| QUOTA-01 | Исчерпанная квота не отключала пира никогда | P0 | закрыт |
| AUTH-03 | Параллельные подключения превышали `maxDevices` | P1 | закрыт |
| GATE-01 | Гейт fail-open срабатывал на корректном коде | P1 | закрыт |
| UX-12 | Импорт не разбирал свой исход и не обновлял список | P2 | закрыт |
LOG-04, LOG-05, AUTH-02, CORE-02, UX-08…UX-11 и TYPE-01 в исходный разбор не
входили и найдены при проверке его выводов по коду. UX-11 нашёлся позже
остальных — при проверке уже внесённых исправлений.
CORE-03…06, AUTH-03 и QUOTA-01 — второй проход разбора, уже по состоянию после
принятых исправлений. CORE-07, GATE-01 и UX-12 найдены при их закрытии.
---
## UX-06 — отключение пира не отзывало доступ
@@ -66,13 +78,17 @@ POST /kick -> разрывает текущую сессию
**Как закрыто.**
1. `service.DisconnectPeers(ids)` — только официальный `/kick`, без единой
записи в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял
`banned_until`, поэтому воспользоваться им для отключения было нельзя:
операция записала бы заодно временную блокировку — другой механизм с другим
сроком жизни и другим способом снятия.
1. Отдельный примитив разрыва — только официальный `/kick`, без единой записи
в базу. Прежний `Hysteria2Kick` вместе с разрывом проставлял `banned_until`,
поэтому воспользоваться им для отключения было нельзя: операция записала бы
заодно временную блокировку — другой механизм с другим сроком жизни и другим
способом снятия. (Тогда он назывался `DisconnectPeers` и принимал
идентификаторы пиров; во втором проходе стал `disconnectAuthIDs` — см.
CORE-03 и CORE-05, где старый `authId` нужен уже после его исчезновения из
базы.)
2. `UpdatePeer` при `disabled=1` выполняет обе половины: сначала долговременную
запись, затем разрыв.
запись, затем разрыв. (Во втором проходе перечень операций расширен — см.
CORE-04.)
3. Порядок обратному не подлежит. При обратном клиент успевает
переподключиться в окне между `/kick` и записью и остаётся на связи с
формально отключённым пиром. Порядок доказывается тестом, который снимает
@@ -164,9 +180,10 @@ machine-auth, то есть на пути каждого подключения
применилась. Операция отвечала чистым отказом, находясь в применённом
состоянии.
Закрыто тем же примитивом, что и UX-06: долговременная запись, затем
`DisconnectPeers`, затем — при неудаче разрыва — частичный результат отдельным
кодом.
Закрыто тем же примитивом, что и UX-06: долговременная запись, затем разрыв,
затем — при неудаче разрыва — частичный результат отдельным кодом. Во втором
проходе этот путь стал общим для всех операций отзыва — `reconcileLiveSessions`
(см. CORE-04).
Механизмы остались независимыми: `banned_until` истекает сам, `disabled`
снимается только руками; включение пира не сбрасывает временную блокировку, а
@@ -359,6 +376,266 @@ EX-03: она обещала более узкий набор, чем серве
---
## QUOTA-01 — исчерпанная квота не отключала пира никогда
Правило доступа существовало в двух экземплярах, написанных разными условиями в
разных местах.
Авторизация прятала его в выборке:
```sql
disabled = 0
and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
and (expires_at = 0 or ? < expires_at)
and ? > banned_until
```
Принудительное отключение — в своей:
```sql
disabled = 1
or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes)
or (expires_at > 0 and ? > expires_at)
or ? < banned_until
```
Второе условие **не является отрицанием первого**, и расхождение приходилось
ровно на границы:
| состояние | авторизация | принудительное отключение |
| --- | --- | --- |
| `quota = 0` | отказ | сессию не рвёт |
| `usage = quota` | отказ | сессию не рвёт |
| `now = expiresAt` | отказ | сессию не рвёт |
| `now = bannedUntil` | отказ | сессию не рвёт |
Хуже всего вела себя исчерпанная квота. `quota_bytes < download + upload`
требует СТРОГОГО превышения, а счётчики растут порциями по ответу Traffic Stats
API — попадание в точное равенство является обычным исходом очередного сбора, а
не экзотикой. Пир с исчерпанной квотой не пускался заново, но его живая сессия
не разрывалась никогда: он продолжал пользоваться доступом, пока не
переподключался по своей воле.
**Как закрыто.** Политика вынесена из SQL в одну функцию `peerAccessDenied`
(`apps/service/peer_access.go`); авторизация ищет пира только по
`secret_digest`, а cron применяет ту же функцию к пирам, которых Hysteria
назвала онлайн. Расходиться им теперь физически негде. Границы зафиксированы
таблицей в `docs/admin/04-admin-panel.md` и точечными тестами: набор проверок
состоит в основном из равенств, потому что расходились именно они.
Отдельно: `quota = -1` объявлен единственным каноничным способом снять
ограничение, `quota = 0` означает ноль байтов. Отрицательное значение любой
величины трактуется как безлимит — так же, как это делала выборка авторизации;
через двери продукта значение меньше `-1` недостижимо.
---
## CORE-03 — удаление пира не отзывало доступ
`DeletePeer` состоял из одной строки:
```go
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
```
Строка исчезала, живая QUIC-сессия оставалась. Хуже того, вместе со строкой
исчезал `auth_id` — единственное, чем эту сессию можно было бы завершить.
Состояние становилось **невосстановимым**: удалённый пир пользовался доступом,
пока не переподключался по своей воле, и сделать с этим было уже нечего.
**Как закрыто.** Порядок: прочитать пира и запомнить `authId` → записать
`disabled=1``/kick` по запомненному значению → удалить строку. Неудача
разрыва оставляет строку на месте отключённой, поэтому новые подключения
запрещены, а оператор повторяет удаление. Отката после `/kick` нет.
Контроллер переведён на `failService`: удаление умеет завершиться частично, и
через `vo.Fail` этот исход уезжал бы панели неотличимо от полного отказа.
---
## CORE-04 — разрыв выполнялся только при отключении
Условие было одно:
```go
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
```
Мимо него проходили четыре операции, каждая из которых закрывает доступ:
```text
смена секрета старые учётные данные недействительны, сессия жива
урезание квоты «100 ГБ -> 5 ГБ» при израсходованных 10 ГБ
перенос срока «истекает завтра» -> «истёк вчера»
снижение лимита «5 устройств -> 1» при пяти подключённых
```
Панель показывала новое состояние, а пир продолжал пользоваться доступом по
старому — тот же дефект, что и UX-06, только под другими именами полей.
**Как закрыто.** `updateRequiresReconcile` принимает решение по снимку «до» и
запрошенным изменениям. Квота и срок проверяются через ту же
`peerAccessDenied`, поэтому «закрывает доступ» здесь и «не пустит при следующем
подключении» — буквально одно условие.
Правило асимметрично намеренно: ограничение применяется немедленно,
послабление — нет. При любом сочетании изменений уходит ровно один `/kick`.
---
## CORE-05 — импорт не завершал сессии переписанных пиров
Импорт переписывает `auth_id`, `secret_digest`, `quota_bytes`, `expires_at` и
`disabled` существующего пира целиком, но сессий не трогал вовсе.
**Как закрыто.** Старые `authId` собираются ВНУТРИ транзакции, разрыв идёт
ПОСЛЕ commit. Оба слова существенны: внутри — потому что после commit старого
значения в базе уже нет; после — потому что `/kick` до commit оставляет клиенту
окно, в котором он переподключается к ещё не изменённому пиру.
Рвутся сессии всех существующих записей партии, а не тех, у кого изменилось
конкретное поле. Это сознательно более простой контракт, чем diff по семи
полям: не появляется второй таблицы правил «какие поля импорта считаются
access-changing» — то есть второго места, где политика может разойтись с
`peerAccessDenied`. Вновь созданные пиры не рвутся: до импорта их сессий
существовать не могло.
---
## CORE-06 и CORE-07 — джоба учёта
**CORE-06.** Устройство было таким:
```go
CronHandleAccount()
-> go func()
-> go saveAccountTraffic()
-> go kickAccount()
```
Для планировщика джоба заканчивалась почти мгновенно — сразу после запуска
внешней горутины. `StopCron()`, который честно ждёт `scheduler.Stop().Done()`,
не ждал НИЧЕГО из настоящей работы: планировщик отчитывался «джоб не осталось»,
`releaseResource()` закрывал SQLite, а внутренние горутины продолжали писать в
закрытое соединение.
Второе следствие того же устройства было тише. Обе половины запускались
параллельно, поэтому принудительное отключение читало счётчики ДО того, как в
них попадала только что снятая дельта: превышение квоты замечалось в лучшем
случае со следующего тика, а на границе — не замечалось вовсе.
**CORE-07** — три nil-разыменования на том же пути, и все внутри горутин, где
их некому перехватить, то есть каждое роняет процесс целиком вместе с
обработчиком machine-auth:
```go
*trafficSecretConfig.Value // строка config без значения
*item.AuthId // строка пира с NULL auth_id
*item.Id // строка пира без идентификатора (CronResetTraffic)
```
Заодно: при пустом наборе целей в Hysteria уезжал `POST /kick` с пустым
массивом в теле — каждые 30 секунд.
**Как закрыто.** Джоба синхронна, под одним `accountJobMutex` на весь цикл
(`trafficMutex` и `kickMutex` удалены — они защищали каждую половину от самой
себя, но не защищали пару от расщепления). Порядок строгий: сбор трафика, затем
enforcement. Секрет берётся общей `hysteria2TrafficSecret()`, которая отличает
«ключа нет» от пустого значения. Повреждённые строки пропускаются с записью в
журнал. Гейт `Hysteria2IsRunning` удалён: `util.Exec` не отличает «служба
неактивна» от «спросить не удалось», поэтому сломанный `systemctl` при живой
Hysteria молча отключал и учёт, и enforcement — без единой строки в журнале.
**Что осталось известным ограничением.** `GET /traffic?clear=1` деструктивен:
счётчики Hysteria обнуляются сразу после отправки ответа, поэтому дельта,
которую не удалось записать в SQLite, потеряна безвозвратно. Раньше такой отказ
делал `continue` и не оставлял следа в исходе джобы; теперь каждая потеря
считается и попадает в ошибку цикла. Полное решение требует смены модели учёта
(недеструктивное чтение плюс долговременные checkpoint'ы) и в `1.0.0` намеренно
не вводится: квота — операционный предел доступа, а не учёт с финансово
значимым каждым байтом.
---
## AUTH-03 — параллельные подключения превышали лимит устройств
Между чтением `/online` и ответом «allow» место ничем не удерживалось:
```text
A: GET /online -> 2 B: GET /online -> 2
max = 3
A: 2 < 3 -> allow B: 2 < 3 -> allow
стало 4
```
Мьютекс вокруг `/online` это не чинит, и это главное в дефекте. Ответив
«allow», админка не создаёт подключение — его только начинает устанавливать
Hysteria, и клиент попадает в статистику позже. Следующий `/online`, даже
строго после первого, продолжает показывать прежнее число; сериализация лишь
сузила бы окно.
**Как закрыто.** Process-local учёт выданных, но ещё не проявившихся разрешений
(`apps/service/peer_admission.go`). Решение принимается по сумме «подключено
плюс зарезервировано»; рост `online` снимает соответствующее число резерваций,
протухшие снимаются по TTL. Сетевой запрос выполняется вне блокировки: под ней
остаются только операции с map.
TTL — 30 секунд, величина внутренняя и пользовательской настройкой не является:
это компенсация задержки между ответом авторизации и появлением клиента в
статистике, а не политика доступа. Выбор fail-closed: в аномальном случае
возможен короткий ложный отказ, но параллельные auth больше не перепрыгивают
лимит.
Ни Redis, ни таблицы в базе, ни распределённых блокировок: HY2XS — один процесс
на одном сервере с Hysteria.
Чего механизм не обещает: без обратного вызова от Hysteria «соединение
установлено / не установлено» математически точной системы резервирования не
построить.
---
## GATE-01 — гейт fail-open срабатывал на корректном коде
Найдено при переписывании приёмки. Проверка «авторизация не возвращает успех из
ветки ошибки» была записана так:
```js
const failOpen = /err != nil \{[\s\S]*?return \*peer\.Id/;
```
Ленивый `[\s\S]*?` свободно пересекает границы блоков, поэтому регулярка
срабатывала на ЛЮБОЙ функции, где после какой-нибудь проверки ошибки где-то
ниже стоит успешный возврат. Проверено прямо на коде из `HEAD`: на корректной
реализации она даёт совпадение, то есть гейт нельзя удовлетворить, не сломав
продукт.
**Как закрыто.** Тело ветки выделяется по балансу фигурных скобок — тогда
«внутри ветки» действительно означает внутри ветки. Логика гейта проверена в
обе стороны: на настоящей дыре срабатывает, на корректном коде — нет.
---
## UX-12 — импорт не разбирал свой исход
`importPeerApi` не объявлял `skipErrorToast`, а `handleImport` не имел ни
`try`, ни `catch`. Пока импорт не умел завершаться частично, это было незаметно.
После CORE-05 отказ уходил бы необработанным отклонением промиса, `handleQuery()`
до выполнения не доходил — список оставался с прежними данными при уже
изменённой базе, — а общий перехватчик показывал бы частичный результат красной
ошибкой, то есть сообщал бы оператору обратное тому, что произошло.
**Как закрыто.** Импорт разбирает исход тем же `reportPeerActionError`, что и
действия строки, а файл убирается из очереди и список обновляется при любом
исходе.
Заодно формулировка `peer_disconnect_failed` во всех трёх местах (сервер и обе
локали) сделана **operation-neutral**. Прежняя — «новые подключения пира
запрещены» — была верна ровно для отключения пира; теперь через этот код
отчитываются восемь операций, а для удалённого пира она просто бессмысленна.
---
## Чем закреплено
**Тесты Go** (`apps/service/peer_access_test.go`,
@@ -379,20 +656,68 @@ EX-03: она обещала более узкий набор, чем серве
* непустой `msg` вместе с отсутствием в нём токена и query-строки;
* форма ответа страницы логов на всех ветках и пропуск битой строки.
**Тесты второго прохода** (`peer_access_policy_test.go`,
`peer_reconcile_test.go`, `cron_test.go`, `peer_admission_test.go`):
* границы политики доступа точечно — набор состоит в основном из равенств,
потому что расходились именно они; fail-closed на повреждённой строке;
* удаление: `disabled` записан ДО `/kick` (снимком базы в момент прихода
запроса), строка остаётся при неудаче разрыва, повторяемость;
* правка: ротация секрета, урезание квоты ниже расхода и ровно по расходу,
перенос срока в прошлое, снижение лимита устройств — каждое рвёт сессию;
послабления и косметика — нет; сочетание изменений даёт ОДИН `/kick`;
* импорт: старый `authId` после commit, откат партии не рвёт ничего, batch с
дедупликацией, частичный результат при неудаче разрыва;
* cron: границы `usage == quota`, `quota == 0`, истёкший срок и истёкшая
блокировка; сбор трафика ДО enforcement (снимком расхода в момент `/online`);
синхронность джобы; пропуск наложенного тика; отсутствие паники на пустом
секрете и на строке без идентификатора; работа при systemd, отвечающем
«служба неактивна»;
* лимит устройств под нагрузкой: два одновременных запроса на последнее
свободное место — барьер на стороне Traffic Stats API держит оба до тех пор,
пока оба не прочитают одно и то же состояние. Проверено, что тест ловит
прежнюю реализацию: с ней проходят оба запроса.
`go test -race ./service/...` — отдельный обязательный шаг сборки: состояние
трекера разрешений и мьютекс цикла учёта принадлежат процессу, и их
корректность не наблюдаема в обычном прогоне.
**Контрактные тесты панели** (`tools/test/frontend-contract.test.ts`): общий
`LogViewer` на обеих страницах, явные ширины колонок, запрос внутри `try`,
единственность сборки скачивания, меню на `command` с пунктом
`toggle-disabled`, ограничение ширины подсказки, единственный
`ElMessageBox.confirm`, разбор частичного результата по коду, совпадение
подсказки имени с серверной константой.
подсказки имени с серверной константой, разбор исхода импорта с обновлением
списка при любом результате.
**Гейты приёмки** (`tools/build/lib/acceptance.sh`):
`run_access_revocation_acceptance` и `run_observability_acceptance`.
Гейты закрывают архитектурные инварианты, а не поведение:
```text
правило доступа объявлено один раз, и колонок политики нет в SQL;
production POST /kick достижим только через disconnectAuthIDs;
disconnectAuthIDs не читает и не пишет состояние пира;
в CronHandleAccount нет отсоединённых горутин;
Hysteria2IsRunning не участвует в cron и в авторизации;
DeletePeer завершает сессию до удаления строки;
импорт разрывает сессии после COMMIT;
Delete и Import отвечают структурированной ошибкой;
детектор гонок обязателен и не имеет обходов.
```
Что гейтами НЕ доказывается и намеренно оставлено тестам: что удаление
действительно сохраняет `disabled` до разрыва, что импорт рвёт именно старый
`authId`, что лимит устройств выдерживает параллельные запросы. Это поведение, и
grep о нём сказать ничего не может.
Отдельно: проверка «единственный `ElMessageBox.confirm`» сначала поймала
собственный комментарий, объясняющий, почему прямого вызова здесь больше нет, —
ровно та ловушка, о которой предупреждает `code_without_comments` в
`acceptance.sh`. Проверки панели теперь тоже отбрасывают комментарии.
`acceptance.sh`. Проверки панели теперь тоже отбрасывают комментарии. Тот же
класс дал GATE-01: проверка, написанная регуляркой по тексту, срабатывала на
корректном коде.
---
@@ -418,3 +743,31 @@ EX-03: она обещала более узкий набор, чем серве
9. проверить ширину подсказки «Экспорт настроек» на узком экране;
10. проверить, что отмена любого подтверждения не оставляет ошибок в консоли
браузера.
Добавлено вторым проходом:
11. **удалить пира с активным подключением** и убедиться, что соединение
обрывается, а не только исчезает строка;
12. остановить `hysteria-server`, удалить пира — строка обязана остаться в
списке отключённой, с предупреждением о частичном результате; поднять
службу и повторить удаление;
13. **сменить секрет** пира с активным подключением: соединение обрывается,
старая клиентская ссылка перестаёт работать, новая работает;
14. **урезать квоту** ниже израсходованного у подключённого пира — соединение
обрывается немедленно, а не со следующим тиком cron;
15. **перенести срок** действия в прошлое — то же;
16. **снизить лимит устройств** у пира с несколькими подключениями: все
обрываются, после переподключения проходит новое разрешённое число;
17. **импортировать файл** с уже существующими пирами — их соединения
обрываются один раз; вновь созданные пиры не затрагиваются; при
остановленной Hysteria импорт применяется целиком и сообщает о частичном
результате, а список обновляется;
18. **израсходовать квоту до нуля** на живой сессии и дождаться тика cron:
соединение обрывается (раньше — не обрывалось никогда);
19. дождаться истечения срока действия на живой сессии — то же;
20. остановить `systemctl` (не Hysteria) и убедиться, что учёт трафика и
принудительное отключение продолжают работать;
21. подключить **одновременно** больше устройств, чем разрешено, и убедиться,
что принято ровно `maxDevices`;
22. перезапустить админку под нагрузкой и убедиться, что в журнале нет записей
о работе с закрытой базой после остановки.
+240 -18
View File
@@ -537,19 +537,58 @@ upstream выберет для нового секрета. Список мар
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
### Правило доступа объявлено один раз
Пускать пира или нет — решает одна функция, `peerAccessDenied`
(`apps/service/peer_access.go`). Её же применяет принудительное отключение в
cron. Второго экземпляра правила в продукте нет, и это главное свойство слоя
доступа.
Границы:
| условие | результат |
| --- | --- |
| `disabled = 1` | доступа нет |
| `quotaBytes = -1` | квота не ограничена |
| `download + upload >= quotaBytes` (при `quotaBytes >= 0`) | доступа нет |
| `expiresAt > 0` и `now >= expiresAt` | доступа нет |
| `bannedUntil > now` | доступа нет |
| строка без любого из этих полей | доступа нет |
Каждая граница выбрана по смыслу самого названия, и три из них стоит назвать
отдельно:
* **`quotaBytes = 0` — это ноль байтов, а не безлимит.** Единственный способ
снять ограничение — `-1`.
* **`usage = quota` — лимит исчерпан.** Счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного
сбора, а не экзотика.
* **`bannedUntil = now` — блокировка уже закончилась.** Она задаётся как «до»
момента, и наступивший момент означает её конец.
Строка без решающего поля трактуется как повреждённая: все эти колонки
объявлены `NOT NULL DEFAULT`, поэтому `NULL` здесь означать может только
повреждение, а на пути принятия решения о доступе оно обязано вести к отказу.
Что было до этого: правило существовало в двух экземплярах — SQL-условием
внутри `Hysteria2Auth` и другим SQL-условием внутри cron, — и расходилось ровно
на перечисленных границах. Практическое следствие было хуже расхождения: пир с
исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда, потому что cron требовал СТРОГОГО превышения. Он продолжал
пользоваться доступом, пока не переподключался по своей воле.
### Отзыв доступа к VPN состоит из двух половин
Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет
целиком — и здесь у неё есть ровно один механизм, требующий обеих половин
официального контракта Hysteria.
целиком — и здесь требуются обе половины официального контракта Hysteria.
```text
disabled = 1 закрывает БУДУЩИЕ обращения к HTTP-auth
POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию
сохранённое состояние закрывает БУДУЩИЕ обращения к HTTP-auth
POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию
```
Ни одна половина не работает по отдельности. Запись `disabled=1` видит только
выборка в `Hysteria2Auth`, то есть проверяется при следующем подключении;
Ни одна половина не работает по отдельности. Сохранённое состояние видит только
`peerAccessDenied`, то есть оно проверяется при следующем подключении;
установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно:
`/kick` завершает сессию, но клиент немедленно переподключается — поэтому
официальная документация Hysteria и требует одновременной блокировки в auth
@@ -558,18 +597,106 @@ backend.
**Порядок обязателен и обратному не подлежит:**
```text
1. записать disabled = 1 (долговременное состояние)
2. POST /kick по authId пира (разрыв)
1. записать долговременное состояние
2. POST /kick по authId пира
```
При обратном порядке клиент успевает переподключиться в окне между разрывом и
записью и остаётся на связи с формально отключённым пиром.
записью и остаётся на связи с уже изменённым пиром.
**Неудача второго шага не откатывает первый.** Безопасная половина достигнута;
возвращать пиру полный доступ из-за отказа разрыва нельзя. Операция отвечает
частичным результатом с кодом `peer_disconnect_failed`, панель показывает его
предупреждением и обновляет строку. Повторить операцию можно тем же действием:
условие смотрит на запрошенное состояние, а не на переход из включённого.
#### Какие операции проходят по этому пути
Разрыв нужен не только при отключении пира. Полный список — и это ровно те
операции, которые способны сделать живую сессию устаревшей:
| операция | что рвётся |
| --- | --- |
| отключение пира (`disabled = 1`) | сессия пира |
| временная блокировка | сессия пира |
| смена секрета | сессия пира: прежние учётные данные недействительны |
| квота урезана так, что доступ уже закрыт | сессия пира |
| срок перенесён в прошлое | сессия пира |
| лимит устройств снижен | все сессии пира |
| **удаление пира** | сессия пира, по запомненному `authId` |
| **импорт партии** | сессии всех существующих пиров партии, по СТАРЫМ `authId` |
Правило асимметрично намеренно: **ограничение применяется немедленно,
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
ронять работающее соединение, расширяя пиру права.
Все они идут через один `reconcileLiveSessions`, а он — через единственный в
продукте вход к `/kick`, `disconnectAuthIDs`. Отдельных методов разрыва для
каждой операции нет намеренно: иначе «изменение применили, а сессию завершить
забыли» появлялось бы заново с каждой новой операцией — именно так это и
случилось с удалением и импортом.
#### Удаление пира
```text
1. прочитать пира и запомнить его authId
2. записать disabled = 1
3. POST /kick по запомненному authId
4. удалить строку
```
Шаг 1 существует потому, что вместе со строкой исчезает `authId` — то есть
единственное, чем сессию можно было бы завершить. Прежняя реализация состояла
из одного шага 4, и состояние после неё было **невосстановимым**: удалённый пир
пользовался доступом до собственного переподключения, и сделать с этим было уже
нечего.
Исходы:
| что произошло | состояние |
| --- | --- |
| запись не удалась | строка не изменена, удаления не было |
| разрыв не удался | строка осталась с `disabled = 1`, новые подключения запрещены |
| разрыв прошёл, удаление не удалось | строка отключена, сессия уже завершена |
Ни один не возвращает пиру доступ. Оператор повторяет удаление тем же
действием.
#### Импорт партии
Импорт — это bulk state replacement: он переписывает `authId`, секрет, квоту,
срок и `disabled` существующего пира целиком. Поэтому:
```text
валидация партии
подготовка криптоматериала
транзакция: собрать СТАРЫЕ authId + применить все изменения
COMMIT
дедупликация + POST /kick одной пачкой
```
Оба слова в «внутри транзакции, после commit» существенны. **Внутри** — потому
что после commit старого `authId` в базе уже нет. **После** — потому что `/kick`
до commit оставляет клиенту окно, в котором он переподключается к ещё не
изменённому пиру.
Рвутся сессии **всех** существующих записей партии, а не тех, у кого изменилось
конкретное поле. Это сознательно более простой контракт, чем diff по семи
полям: не появляется второй таблицы правил «какие поля импорта считаются
access-changing», то есть второго места, где политика может разойтись с
`peerAccessDenied`. Цена — существующие пиры партии один раз переподключаются;
для административной операции переноса это нормальная цена. Вновь созданные
пиры не рвутся: до импорта их сессий существовать не могло.
#### Частичный результат
**Неудача разрыва не откатывает сохранённое состояние.** Безопасная половина
достигнута; возвращать доступ из-за отказа второго шага нельзя. Операция
отвечает кодом `peer_disconnect_failed`, панель показывает его предупреждением
и обновляет список.
Формулировка сообщения **не называет конкретную операцию**: через этот код
отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые
подключения пира запрещены» была бы просто бессмысленной.
**Отключение и временная блокировка — разные механизмы**, и смешивать их
нельзя:
@@ -579,14 +706,49 @@ backend.
| `disabled` | только руками оператора | отзыв доступа |
| `banned_until` | истекает сам | временная блокировка |
Поэтому `DisconnectPeers` не пишет в базу вовсе, включение пира не сбрасывает
`banned_until`, а снятие блокировки не включает отключённого пира.
Поэтому `disconnectAuthIDs` не пишет в базу вовсе и не читает её: он принимает
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
блокировки не включает отключённого пира.
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
для отказа операции, ни для её пропуска. Ответ даёт само обращение к Traffic
Stats API.
для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само
обращение к Traffic Stats API.
### Цикл учёта принадлежит планировщику
`CronHandleAccount` выполняется синхронно, под одним мьютексом на весь цикл, и
строго в этом порядке:
```text
TryLock (пропустить тик, если предыдущий ещё идёт)
порт Traffic Stats API + секрет
GET /traffic?clear=1 → записать дельты в счётчики пиров
GET /online → применить peerAccessDenied → POST /kick
```
Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики
должны быть уже обновлены. Раньше обе половины запускались параллельными
горутинами внутри ещё одной горутины, поэтому превышение квоты замечалось в
лучшем случае со следующего тика, а планировщик считал джобу завершённой почти
мгновенно — `StopCron()` не ждал настоящей работы, и после закрытия SQLite
горутины продолжали в неё писать.
**Учёт трафика — операционная граница, а не биллинг.** Чтение `GET
/traffic?clear=1` деструктивно по контракту Traffic Stats API: счётчики
Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта
существует ровно в одном экземпляре. Если запись в SQLite не удалась, дельта
потеряна безвозвратно — это записывается в журнал уровнем `error`, но не
компенсируется. Полностью закрыть окно можно только сменой модели учёта:
недеструктивный `GET /traffic` плюс долговременные checkpoint'ы верхних
счётчиков и вычисление дельты на стороне админки. Это отдельная подсистема с
обработкой перезапуска и сброса счётчиков Hysteria, и в `1.0.0` она намеренно
не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово
значимым каждым байтом.
### Ограничение устройств проверяется fail-closed
@@ -609,6 +771,47 @@ Hysteria, значит она жива, а её Traffic Stats API слушает
показывают пустую картину, когда служба остановлена, — это честный ответ на
вопрос «кто сейчас на связи».
#### Лимит выдерживает параллельные подключения
Сравнения ответа `/online` с `maxDevices` недостаточно. Ответив «allow», панель
не создаёт подключение — его только начинает устанавливать Hysteria, и клиент
попадает в статистику позже. Поэтому:
```text
A: GET /online -> 2 B: GET /online -> 2
max = 3
A: 2 < 3 -> allow B: 2 < 3 -> allow
стало 4
```
Объявленный «Лимит устройств: 3» превышался ровно тем способом, от которого
лимит и должен защищать. Мьютекс вокруг `/online` это не чинит: следующий
запрос, даже строго после первого, продолжает видеть прежнее число.
Панель ведёт собственный учёт уже выданных, но ещё не проявившихся разрешений
(`apps/service/peer_admission.go`):
```text
1. обычная проверка политики доступа
2. GET /online (вне блокировки: сеть не должна сериализовать все подключения)
3. снять протухшие разрешения
4. рост online означает, что столько же разрешений превратились в подключения
5. решение по сумме: online + выданные разрешения
6. свободно -> занять место и allow; иначе deny
```
Учёт **process-local**: HY2XS — один процесс на одном сервере с Hysteria, и ни
Redis, ни таблицы в базе, ни распределённых блокировок для этого не нужно.
Разрешение живёт 30 секунд — величина внутренняя и пользовательской настройкой
не является: это компенсация задержки между ответом авторизации и появлением
клиента в статистике, а не политика доступа. Если клиент авторизовался и не
подключился, резервация исчезает сама.
Чего механизм не обещает: без обратного вызова от Hysteria «соединение
установлено / не установлено» математически точной системы резервирования не
построить. Он закрывает конкретный и реальный случай — параллельные HTTP-auth
одного процесса — и делает это fail-closed.
### Что нельзя делать
- собирать admin-компонент на target server;
@@ -621,6 +824,15 @@ Hysteria, значит она жива, а её Traffic Stats API слушает
- откатывать `disabled` из-за неудачи `/kick`;
- писать `banned_until` из пути отключения пира;
- пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
- заводить второй предикат доступа рядом с `peerAccessDenied` — в том числе в
виде SQL-условия внутри выборки;
- обращаться к `/kick` мимо `disconnectAuthIDs`;
- удалять пира, не запомнив его `authId` и не завершив сессию до удаления;
- разрывать сессии импорта до `COMMIT` либо по новым `authId`;
- запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан
её видеть, иначе `StopCron()` вернётся раньше, чем она закончит;
- считать квоту биллинговым учётом: чтение `/traffic?clear=1` деструктивно;
- делать срок жизни pending-разрешения пользовательской настройкой;
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
- выгружать конфиг с секретами в открытом виде.
@@ -791,3 +1003,13 @@ Compatibility-ветка пережила слой совместимости,
15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
16. любой журнал, покидающий сервер, проходит санитайз
17. пароль администратора хранится ровно в одном формате — bcrypt
18. правило доступа объявлено ровно один раз (`peerAccessDenied`), и авторизация
с принудительным отключением спрашивают именно его
19. каждая операция, способная сделать живую сессию устаревшей, проходит через
один `reconcileLiveSessions`, а он — через единственный вход к `/kick`
20. долговременное состояние записывается ДО разрыва, и неудача разрыва его не
откатывает
21. удаление пира завершает его сессию до того, как исчезнет `authId`
22. импорт разрывает старые сессии после `COMMIT` и по старым `authId`
23. джоба учёта выполняется синхронно, и `StopCron()` её дожидается
24. лимит устройств не превышается параллельными запросами авторизации