8dcb50a07c
Предыдущий проход сделал правильным порядок «сначала долговременная запись, потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не работал вовсе. Импорт, заменивший 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
173 lines
10 KiB
Go
173 lines
10 KiB
Go
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
|
||
}
|