162759c599
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.
Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.
Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.
Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.
Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.
Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
168 lines
7.6 KiB
Go
168 lines
7.6 KiB
Go
import axios, {
|
|
AxiosError,
|
|
AxiosResponse,
|
|
InternalAxiosRequestConfig,
|
|
} from "axios";
|
|
import { useAdminStoreHook } from "@/store/modules/admin";
|
|
import i18n from "@/lang/index";
|
|
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
|
|
import { describeApiError } from "@/utils/api-message";
|
|
|
|
const dynamicBase = (window as any).__dynamic_base__ || "";
|
|
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
|
|
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
|
|
// админский API, хотя middleware у них разные.
|
|
// Значение синхронизировано с constant.AdminAPIBase в админке и
|
|
// ADMIN_API_BASE в оркестраторе.
|
|
const API_BASE = "/api";
|
|
const t = i18n.global.t;
|
|
|
|
/**
|
|
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
|
*
|
|
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
|
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
|
|
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
|
|
* предупреждением, а не ошибкой.
|
|
*
|
|
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
|
|
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
|
|
* и молчание о нём означало бы операцию, не происходящую без объяснений.
|
|
*/
|
|
declare module "axios" {
|
|
export interface AxiosRequestConfig {
|
|
skipErrorToast?: boolean;
|
|
}
|
|
}
|
|
|
|
// Создание axios instance
|
|
const service = axios.create({
|
|
baseURL: `${dynamicBase}${API_BASE}`,
|
|
timeout: 50000,
|
|
headers: { "Content-Type": "application/json;charset=utf-8" },
|
|
});
|
|
|
|
// Request interceptor
|
|
service.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
const adminStore = useAdminStoreHook();
|
|
if (adminStore.token) {
|
|
config.headers.Authorization = adminStore.token;
|
|
}
|
|
return config;
|
|
},
|
|
(error: any) => {
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Сессия кончилась под руками у оператора.
|
|
*
|
|
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
|
|
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
|
|
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
|
|
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
|
|
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
|
|
* Ключ common.sessionExpired существовал и был мёртвым.
|
|
*
|
|
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
|
|
* параллельных запросов страницы, и без этого оператор получил бы стопку
|
|
* одинаковых окон.
|
|
*/
|
|
let sessionPromptOpen = false;
|
|
|
|
function promptSignIn(expired: boolean): void {
|
|
if (sessionPromptOpen) {
|
|
return;
|
|
}
|
|
sessionPromptOpen = true;
|
|
|
|
const finish = () => {
|
|
sessionPromptOpen = false;
|
|
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
|
|
// есть заодно стирал выбранный оператором язык панели: при следующем входе
|
|
// интерфейс возвращался к значению по умолчанию без всякой причины.
|
|
useAdminStoreHook().resetToken();
|
|
const redirect = encodeURIComponent(
|
|
window.location.pathname + window.location.search
|
|
);
|
|
window.location.href = `/login?redirect=${redirect}`;
|
|
};
|
|
|
|
ElMessageBox.confirm(
|
|
expired ? t("common.sessionExpired") : t("common.signInRequired"),
|
|
t("common.warning"),
|
|
{
|
|
confirmButtonText: t("common.signIn"),
|
|
showCancelButton: false,
|
|
closeOnClickModal: false,
|
|
closeOnPressEscape: false,
|
|
showClose: false,
|
|
type: "warning",
|
|
}
|
|
)
|
|
.then(finish)
|
|
.catch(finish);
|
|
}
|
|
|
|
// Response interceptor
|
|
service.interceptors.response.use(
|
|
(response: AxiosResponse) => {
|
|
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
|
|
// проверяться ДО обращения к его полям: у Blob их нет.
|
|
if (
|
|
response.data instanceof ArrayBuffer ||
|
|
response.data instanceof Blob
|
|
) {
|
|
return response;
|
|
}
|
|
|
|
const payload = response.data as ApiErrorPayload;
|
|
if (payload?.code === API_CODE.success) {
|
|
return response.data;
|
|
}
|
|
|
|
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
|
|
|
|
if (apiError.requiresSignIn) {
|
|
promptSignIn(apiError.sessionExpired);
|
|
return Promise.reject(apiError);
|
|
}
|
|
|
|
if (!response.config?.skipErrorToast) {
|
|
ElMessage.error(describeApiError(apiError));
|
|
}
|
|
return Promise.reject(apiError);
|
|
},
|
|
(error: AxiosError) => {
|
|
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
|
|
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
|
|
// самого error.response — то есть при обрыве соединения падал с
|
|
// TypeError и подменял настоящую причину отказом внутри обработчика.
|
|
const message = error.response
|
|
? t("common.systemError")
|
|
: t("common.networkError");
|
|
|
|
// skipErrorToast здесь НЕ действует, и это существенно.
|
|
//
|
|
// Флаг существует ради одного случая: страница разбирает причину отказа
|
|
// сама — раскладывает её по полям формы или отличает частичный результат
|
|
// от полного отказа по коду. Всё это возможно только для отказа API, у
|
|
// которого есть конверт с `code` и `errors`.
|
|
//
|
|
// У транспортного отказа конверта нет вовсе. Страница о нём не может
|
|
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
|
|
// своих обработчиках `// транспортный отказ уже показан общим
|
|
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
|
|
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
|
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
|
|
// молча.
|
|
ElMessage.error(message);
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Export axios instance
|
|
export default service;
|