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 }