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:
2026-09-02 07:15:43 +05:00
parent 6d1686b2be
commit 8dcb50a07c
16 changed files with 1511 additions and 104 deletions
+125
View File
@@ -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 — то есть
# вместе с единственным, чем можно было бы завершить живую сессию.