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 }