Files
HY2XS_flamy/apps/service/peer_access.go
T
founder 8dcb50a07c fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
2026-09-02 07:15:43 +05:00

173 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"hy2xs-admin/model/entity"
)
// Правило доступа пира объявлено ОДИН РАЗ и живёт в Go, а не в SQL.
//
// Что было. Правило существовало в двух экземплярах, написанных разными
// условиями в разных местах.
//
// Авторизация (Hysteria2Auth) прятала его в выборке:
//
// disabled = 0
// and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
// and (expires_at = 0 or ? < expires_at)
// and ? > banned_until
//
// Принудительное отключение (cron) — в своей выборке, уже с другими границами:
//
// 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 auth отказывает, cron сессию не рвёт
// usage = quota auth отказывает, cron сессию не рвёт
// now = expiresAt auth отказывает, cron сессию не рвёт
// now = bannedUntil auth отказывает, cron сессию не рвёт
//
// Хуже всего вела себя исчерпанная квота. `quota_bytes < download + upload`
// требует СТРОГОГО превышения, а счётчики растут порциями по ответу Traffic
// Stats API, поэтому попадание в точное равенство — не экзотика, а обычный
// исход последнего сбора. Пир с исчерпанной квотой не пускался заново, но его
// живая сессия не разрывалась НИКОГДА: он продолжал пользоваться доступом,
// пока не переподключался по своей воле.
//
// Поэтому политика перестаёт быть частью запроса и становится функцией
// продукта. Авторизация и enforcement физически не могут разойтись, потому что
// спрашивают одно и то же.
//
// Производительность здесь не страдает: авторизация всё равно ищет ОДНУ строку
// по secret_digest, а cron всё равно читает пиров, которых Hysteria назвала
// онлайн.
// peerAccessDenied отвечает на единственный вопрос: закрыт ли доступ пиру
// прямо сейчас.
//
// disabled == 1 -> DENY
// quotaBytes < 0 -> квота не ограничена
// quotaBytes >= 0 && usage >= quotaBytes -> DENY
// expiresAt > 0 && now >= expiresAt -> DENY
// bannedUntil > now -> DENY
// иначе -> ALLOW
//
// Границы выбраны по смыслу самих названий:
//
// quota = -1 единственный способ сказать «без ограничения»;
// quota = 0 нулевая квота — это ноль байтов, а не безлимит;
// usage = quota выданный лимит уже израсходован целиком;
// expiresAt = now срок доступа уже наступил, то есть истёк;
// bannedUntil = now временная блокировка уже закончилась.
//
// Отрицательная квота любой величины означает «без ограничения» — так же, как
// это делала выборка авторизации (`quota_bytes < 0`). Через двери продукта
// значение меньше -1 недостижимо: и dto.PeerSaveDto, и валидация импорта
// требуют `>= -1`. Канон один — `-1`.
func peerAccessDenied(peer entity.Peer, now int64) bool {
// Строка без решающего поля — повреждённые данные, а не пир без
// ограничений.
//
// Все эти колонки объявлены NOT NULL с DEFAULT, поэтому nil здесь может
// означать только повреждение. На пути принятия решения о доступе такая
// строка обязана вести к отказу: молчаливое «поле не задано, значит можно»
// — это ровно тот способ, которым ограничение перестаёт быть ограничением.
if peer.Disabled == nil || peer.QuotaBytes == nil ||
peer.DownloadBytes == nil || peer.UploadBytes == nil ||
peer.ExpiresAt == nil || peer.BannedUntil == nil {
return true
}
if *peer.Disabled == 1 {
return true
}
if *peer.QuotaBytes >= 0 && *peer.DownloadBytes+*peer.UploadBytes >= *peer.QuotaBytes {
return true
}
if *peer.ExpiresAt > 0 && now >= *peer.ExpiresAt {
return true
}
if *peer.BannedUntil > now {
return true
}
return false
}
// Живая сессия сверяется с сохранённым состоянием ПОВТОРЯЕМО.
//
// Что было. Приведение сессий к состоянию базы выполнялось ровно один раз — в
// той же операции, которая это состояние записала. Порядок «сначала запись,
// потом `/kick`» правильный, и откат при неудаче разрыва делать нельзя: часть
// операции, закрывающая доступ, уже достигнута. Но второй попытки после
// неудачи не существовало вовсе, и два состояния оставались навсегда.
//
// Первое — замена `auth_id` импортом:
//
// импорт old-auth -> new-auth, COMMIT прошёл
// /kick old-auth -> 500
// оператор повторяет тот же импорт
// applyPeerImportEntry читает из базы уже new-auth и рвёт ЕГО
//
// Старый идентификатор после первой же неудачи не хранился нигде, а cron его
// пропускал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку, и
// authID, которого нет в базе, молча выпадал из обхода. Живая QUIC-сессия
// удалённого или переподписанного пира продолжалась сколько угодно долго.
//
// Второе — снижение `maxDevices`:
//
// 5 -> 1, запись прошла, /kick -> 500
// оператор повторяет сохранение формы
// updateRequiresReconcile сравнивает 1 < 1 -> false, разрыва нет
//
// Для `disabled` повторяемость сделана специально (условие смотрит на
// ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает
// cron через peerAccessDenied. Лимит устройств в политику доступа не входит и
// входить не должен — это свойство не пира, а его сессий, — поэтому здесь у
// него не было ни одного механизма схождения.
//
// Оба состояния закрывает один и тот же приём: cron сверяет не «кого из
// известных пиров пора отключить», а КАЖДЫЙ authID, который Hysteria считает
// живым. Отдельная таблица retry, очередь отложенных операций и хранимый
// «список того, что не удалось разорвать» для этого не нужны: `/online` и есть
// список живых сессий, и сверять его достаточно.
// peerSessionNeedsReconcile отвечает, устарела ли живая сессия пира.
//
// Вторым экземпляром политики доступа не является: disabled, quota, expiry и
// ban остаются целиком за peerAccessDenied, и эта функция их не повторяет, а
// вызывает. Своего здесь ровно одно — инвариант живых сессий, которого в
// хранимом состоянии пира нет: число подключённых устройств.
//
// доступ закрыт -> сессия устарела
// maxDevices непригоден -> сессия устарела
// устройств больше, чем разрешено -> сессии устарели
//
// Непригодный `maxDevices` ведёт к разрыву по той же причине, по которой он
// ведёт к отказу в авторизации: повреждённая граница — это не «безлимит».
//
// Число устройств берётся из `/online`, который по официальному контракту
// Traffic Stats API возвращает количество экземпляров клиента Hysteria
// («устройства»), а не число proxy-потоков. То есть сравнение с `maxDevices`
// здесь опирается на upstream-контракт, а не на предположение.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/kick` оперирует
// идентификатором клиента. После разрыва клиенты переподключаются, и
// admission пропустит ровно столько, сколько разрешено теперь.
func peerSessionNeedsReconcile(peer entity.Peer, onlineDevices int64, now int64) bool {
if peerAccessDenied(peer, now) {
return true
}
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
return true
}
return onlineDevices > *peer.MaxDevices
}