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
This commit is contained in:
@@ -1688,6 +1688,131 @@ run_access_revocation_acceptance() {
|
||||
code_has apps/service/hysteria2_api.go -F -- 'reserveDeviceSlot(' \
|
||||
|| fail "acceptance: auth does not reserve a device slot"
|
||||
|
||||
log_step "Acceptance: the online snapshot cannot be reordered against admission"
|
||||
# Учёта выданных разрешений одного НЕДОСТАТОЧНО. Сетевой запрос выполнялся
|
||||
# вне мьютекса, поэтому снимки приходили в резервацию в произвольном порядке,
|
||||
# и более старый откатывал `lastOnline` назад:
|
||||
#
|
||||
# A получил разрешение при online = 0
|
||||
# C обработал online = 1 первым: разрешение A признано проявившимся
|
||||
# B пришёл со своим устаревшим 0 -> место снова «свободно» -> ALLOW
|
||||
#
|
||||
# Детектор гонок здесь молчит принципиально: вся работа с памятью защищена
|
||||
# мьютексом, гонка логическая. Единственная защита — замок по authID вокруг
|
||||
# ВСЕЙ последовательности «прочитать /online -> занять место».
|
||||
code_has apps/service/peer_admission.go -F -- 'func lockPeerAdmission' \
|
||||
|| fail "acceptance: the per-peer admission gate is missing"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func Hysteria2Auth");
|
||||
if (start < 0) throw new Error("Hysteria2Auth is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
|
||||
const gate = body.indexOf("lockPeerAdmission(");
|
||||
const online = body.indexOf("hysteria2Online()");
|
||||
const reserve = body.indexOf("reserveDeviceSlot(");
|
||||
if (gate < 0) throw new Error("auth does not take the admission gate");
|
||||
if (online < 0 || reserve < 0) throw new Error("auth no longer reads /online or reserves a slot");
|
||||
// Замок обязан быть взят ДО чтения статистики: взятый после него он
|
||||
// защищал бы только резервацию, а переупорядочиваются именно снимки.
|
||||
if (gate > online) throw new Error("the admission gate is taken after the /online read");
|
||||
if (!/defer\s+\w+\(\)/.test(body)) throw new Error("the admission gate is never released");
|
||||
// Замок именно по authId. Литеральный ключ означал бы один замок на
|
||||
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
|
||||
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
|
||||
throw new Error("the admission gate is not keyed by the peer auth id");
|
||||
}
|
||||
' || fail "acceptance: the /online read and the reservation must be one serialized sequence"
|
||||
|
||||
log_step "Acceptance: cron reconciles every live session, not only known peers"
|
||||
# Прежний обход шёл по НАЙДЕННЫМ пирам, поэтому authID, которого нет в базе,
|
||||
# молча выпадал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку.
|
||||
# Так после неудавшегося второго шага удаления или импорта, заменившего
|
||||
# `auth_id`, живая сессия оставалась навсегда — восстановить её
|
||||
# переподключением уже нельзя, авторизация нового значения не знает.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/cron.go", "utf8");
|
||||
const start = source.indexOf("func enforcePeerAccess");
|
||||
if (start < 0) throw new Error("enforcePeerAccess is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
|
||||
// Обход идёт по идентификаторам из `/online`, а не по строкам выборки.
|
||||
if (!/for\s+_,\s*authID\s*:=\s*range\s+authIDs\s*\{/.test(body)) {
|
||||
throw new Error("enforcePeerAccess no longer walks the online auth ids");
|
||||
}
|
||||
// Мало отличить сессию без строки в базе — её надо ЗАВЕРШИТЬ. Прежний код
|
||||
// ровно на этом месте делал молчаливый пропуск, поэтому ветка проверяется
|
||||
// по содержимому, а не по факту существования.
|
||||
const orphan = body.indexOf("if !found {");
|
||||
if (orphan < 0) {
|
||||
throw new Error("enforcePeerAccess does not distinguish a session without a peer row");
|
||||
}
|
||||
let depth = 0;
|
||||
let orphanEnd = orphan;
|
||||
for (let i = body.indexOf("{", orphan); i < body.length; i++) {
|
||||
if (body[i] === "{") depth++;
|
||||
else if (body[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) { orphanEnd = i; break; }
|
||||
}
|
||||
}
|
||||
if (!/kick\s*=\s*append\(/.test(body.slice(orphan, orphanEnd + 1))) {
|
||||
throw new Error("a live session without a peer row is skipped instead of terminated");
|
||||
}
|
||||
if (!body.includes("peerSessionNeedsReconcile(")) {
|
||||
throw new Error("enforcePeerAccess does not apply the live-session predicate");
|
||||
}
|
||||
// Отказ базы не даёт права рвать сессии: недоступная SQLite иначе
|
||||
// отключила бы всех подключённых пиров сразу.
|
||||
const lookup = body.indexOf("dao.ListPeer(");
|
||||
const kickCall = body.indexOf("disconnectAuthIDs(");
|
||||
if (lookup < 0 || kickCall < 0) throw new Error("enforcePeerAccess lost its lookup or its disconnect");
|
||||
const between = body.slice(lookup, kickCall);
|
||||
if (!/if\s+err\s*!=\s*nil\s*\{\s*return\s+err/.test(between)) {
|
||||
throw new Error("a storage failure no longer stops enforcement before /kick");
|
||||
}
|
||||
if (!body.includes("sweepDeviceAdmissions(")) {
|
||||
throw new Error("the admission tracker is never swept against the real online picture");
|
||||
}
|
||||
' || fail "acceptance: cron must reconcile every live session reported by /online"
|
||||
|
||||
log_step "Acceptance: the live-session predicate does not duplicate the access policy"
|
||||
# peerAccessDenied остаётся единственным владельцем disabled/quota/expiry/ban.
|
||||
# Своего у предиката ровно одно — число подключённых устройств, которого в
|
||||
# хранимом состоянии пира нет.
|
||||
code_has apps/service/peer_access.go -F -- 'func peerSessionNeedsReconcile' \
|
||||
|| fail "acceptance: the live-session predicate is missing"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/peer_access.go", "utf8");
|
||||
const start = source.indexOf("func peerSessionNeedsReconcile");
|
||||
if (start < 0) throw new Error("peerSessionNeedsReconcile is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
|
||||
if (!body.includes("peerAccessDenied(")) {
|
||||
throw new Error("the live-session predicate does not reuse the access policy");
|
||||
}
|
||||
for (const field of ["Disabled", "QuotaBytes", "DownloadBytes", "UploadBytes", "ExpiresAt", "BannedUntil"]) {
|
||||
if (body.includes("peer." + field)) {
|
||||
throw new Error("the access policy is duplicated inside the live-session predicate: " + field);
|
||||
}
|
||||
}
|
||||
' || fail "acceptance: the live-session predicate must not restate peerAccessDenied"
|
||||
|
||||
log_step "Acceptance: deleting a peer revokes access before removing the row"
|
||||
# `return dao.DeletePeer(...)` убирал строку вместе с auth_id — то есть
|
||||
# вместе с единственным, чем можно было бы завершить живую сессию.
|
||||
|
||||
Reference in New Issue
Block a user