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:
+240
-18
@@ -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. лимит устройств не превышается параллельными запросами авторизации
|
||||
|
||||
Reference in New Issue
Block a user