fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
This commit is contained in:
@@ -95,6 +95,15 @@ export function getPeerClientConfigApi(
|
||||
});
|
||||
}
|
||||
|
||||
// Импорт сообщает свой исход сам — по той же причине, что и действия строки
|
||||
// пира.
|
||||
//
|
||||
// Партия применяется одной транзакцией, а после её фиксации завершаются старые
|
||||
// сессии обновлённых пиров. Второй шаг умеет не удаться отдельно от первого, и
|
||||
// тогда ответ несёт peer_disconnect_failed: файл применён целиком, но часть
|
||||
// клиентов остаётся на связи до переподключения. Общий перехватчик показал бы
|
||||
// такой исход красной ошибкой, то есть сообщил бы оператору ровно обратное
|
||||
// тому, что произошло.
|
||||
export function importPeerApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/peer-import",
|
||||
@@ -103,6 +112,7 @@ export function importPeerApi(data: FormData): AxiosPromise {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -164,8 +164,14 @@ export default {
|
||||
// The phrase must open with what has ALREADY been applied, otherwise it
|
||||
// reads as "the operation failed" and the operator repeats an action
|
||||
// that in fact went through.
|
||||
//
|
||||
// It also names no specific operation. This code is reported by
|
||||
// disabling a peer, a temporary ban, secret rotation, quota and expiry
|
||||
// reductions, a lower device limit, a batch import and peer deletion;
|
||||
// the previous "new connections for this peer are now refused" held only
|
||||
// for the first case and is meaningless for a deleted peer.
|
||||
peer_disconnect_failed:
|
||||
"New connections for this peer are now refused, but its active session could not be terminated: the Hysteria Traffic Stats API is unreachable. An established connection may keep working until the client reconnects. Check the hysteria-server service and retry.",
|
||||
"The changes were saved, but the related active sessions could not be terminated: the Hysteria Traffic Stats API is unreachable. Established connections may keep working until the client reconnects. Check the hysteria-server service and retry.",
|
||||
import_file_extension: "Import accepts .json files only",
|
||||
unauthorized: "Signing in is required",
|
||||
session_expired: "Session expired",
|
||||
|
||||
@@ -166,8 +166,14 @@ export default {
|
||||
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
|
||||
// прочитает её как «операция не выполнена» и повторит действие, которое
|
||||
// на самом деле применилось.
|
||||
//
|
||||
// И она НЕ называет конкретную операцию. Этим кодом отчитываются
|
||||
// отключение пира, временная блокировка, смена секрета, урезание квоты и
|
||||
// срока, снижение лимита устройств, импорт партии и удаление пира;
|
||||
// прежнее «новые подключения пира запрещены» было верно ровно для
|
||||
// первого случая, а для удалённого пира — просто бессмысленно.
|
||||
peer_disconnect_failed:
|
||||
"Новые подключения пира запрещены, но завершить его активную сессию не удалось: Traffic Stats API Hysteria недоступен. Установленное соединение может работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
|
||||
"Изменения сохранены, но завершить связанные активные сессии не удалось: Traffic Stats API Hysteria недоступен. Установленные соединения могут работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
|
||||
import_file_extension: "Импорт принимает только файлы .json",
|
||||
unauthorized: "Требуется вход в панель",
|
||||
session_expired: "Сессия истекла",
|
||||
|
||||
@@ -937,13 +937,30 @@ async function showQr(row: PeerVo) {
|
||||
qrDialog.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт выгрузки пиров.
|
||||
*
|
||||
* Исход разбирается тем же обработчиком, что и действия строки: импорт умеет
|
||||
* завершиться ЧАСТИЧНО — партия применена целиком, но завершить старые сессии
|
||||
* обновлённых пиров не удалось.
|
||||
*
|
||||
* Раньше здесь не было ни try, ни catch: отказ уходил необработанным
|
||||
* отклонением промиса, а `handleQuery()` до выполнения не доходил — список
|
||||
* оставался с прежними данными, хотя база уже изменилась. Убирается файл из
|
||||
* очереди и обновляется список ПРИ ЛЮБОМ исходе по той же причине.
|
||||
*/
|
||||
async function handleImport(params: UploadRequestOptions) {
|
||||
if (importFileList.value.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
await importPeerApi(formData);
|
||||
try {
|
||||
await importPeerApi(formData);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
importFileList.value = [];
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user