fix(admin): различать достижимость Traffic Stats API и соответствие профилю

Признак на странице конфигурации отвечал только на вопрос «достучится ли
админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane
при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не
создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический
loopback и недостижимый адрес.

Backend не тронут: он по-прежнему отвечает только на вопрос достижимости —
превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех
пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в
Go означает все интерфейсы, а не loopback.

Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки
операций запуска, остановки и смены версии Hysteria, которых у панели нет.
This commit is contained in:
2026-09-03 03:39:42 +05:00
parent cb20d8d28f
commit b315001288
10 changed files with 204 additions and 30 deletions
+9 -5
View File
@@ -100,9 +100,9 @@ export default {
save: "Save",
update: "Update",
downloadSuccess: "Download successful",
wait: "The version is being changed, please wait a moment",
enableSuccess: "Hysteria2 start successful",
disableSuccess: "Hysteria2 stop successful",
// `wait`, `enableSuccess` and `disableSuccess` are gone together with the
// operations the panel does not have: starting, stopping and changing the
// version of Hysteria. That lifecycle belongs to systemd and the orchestrator.
success: "Success",
refresh: "Refresh",
yes: "Yes",
@@ -335,7 +335,11 @@ export default {
trafficStatsMissing: "Traffic Stats API is not configured",
trafficStatsMissingHint:
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
trafficStatsNotLoopback:
"the address is not loopback: the panel reaches the Traffic Stats API over 127.0.0.1 only",
trafficStatsUnreachable:
"the panel cannot reach this address: the Traffic Stats API is queried over loopback only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work",
trafficStatsWildcard:
"the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
trafficStatsNonCanonical:
"the API is reachable but the address differs from 127.0.0.1 — the orchestrator never produces such a configuration",
},
};
+12 -5
View File
@@ -100,9 +100,9 @@ export default {
save: "Сохранить",
update: "Обновить",
downloadSuccess: "Загрузка завершена",
wait: "Версия меняется, подождите",
enableSuccess: "Hysteria2 запущена",
disableSuccess: "Hysteria2 остановлена",
// Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с
// операциями, которых у панели нет: запуском, остановкой и сменой версии
// Hysteria. Жизненный цикл принадлежит systemd и оркестратору.
success: "Готово",
refresh: "Обновить",
yes: "Да",
@@ -346,7 +346,14 @@ export default {
trafficStatsMissing: "Traffic Stats API не настроен",
trafficStatsMissingHint:
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
trafficStatsNotLoopback:
"адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1",
// Три разные новости об одном поле, и объединять их нельзя: первая
// означает, что доступ пиров уже не работает, вторая — что внутренний API
// опубликован шире необходимого, третья — просто дрейф от профиля.
trafficStatsUnreachable:
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается только по loopback. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
trafficStatsWildcard:
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
trafficStatsNonCanonical:
"API доступен, но адрес отличается от 127.0.0.1 — оркестратор такой конфигурации не создаёт",
},
};
+69 -10
View File
@@ -308,10 +308,25 @@
<span :class="valueClass(profile.trafficStats.listen)">{{
text(profile.trafficStats.listen)
}}</span>
<!--
Недостижимость и «шире, чем нужно» разные новости: первая
означает, что доступ пиров уже не работает, вторая что
внутренний API опубликован лишний раз.
-->
<span
v-if="!trafficStatsIsLoopback"
v-if="trafficStatsListenState === 'unreachable'"
class="value-error"
>{{ $t("hysteria.trafficStatsNotLoopback") }}</span
>{{ $t("hysteria.trafficStatsUnreachable") }}</span
>
<span
v-else-if="trafficStatsListenState === 'wildcard'"
class="value-warning"
>{{ $t("hysteria.trafficStatsWildcard") }}</span
>
<span
v-else-if="trafficStatsListenState === 'nonCanonicalLoopback'"
class="value-warning"
>{{ $t("hysteria.trafficStatsNonCanonical") }}</span
>
</el-descriptions-item>
<el-descriptions-item label="trafficStats.secret">
@@ -424,19 +439,57 @@ function list(values: string[]): string {
}
/**
* Traffic Stats API обязан слушать loopback.
* Состояние адреса Traffic Stats API.
*
* Админка обращается к нему только по 127.0.0.1, поэтому любой другой адрес
* означает, что Hysteria и панель говорят мимо друг друга: лимит устройств,
* учёт трафика и принудительное отключение перестают работать разом.
* Вопросов здесь ДВА, и смешивать их нельзя.
*
* Первый — технический: достанет ли админка до этого адреса. Она обращается к
* API только по loopback, поэтому LAN- или публичный адрес означает, что
* Hysteria и панель говорят мимо друг друга, и лимит устройств, учёт трафика и
* принудительное отключение перестают работать разом.
*
* Второй — профильный: тот ли это адрес, который создаёт оркестратор. У
* production-профиля он ровно один — `127.0.0.1`.
*
* Прежняя версия отвечала на первый вопрос и молчала по второму: `0.0.0.0`
* считался нормой, потому что через него админка достучится. Но wildcard — не
* loopback: внутренний control plane при нём опубликован на всех интерфейсах, и
* при `HY2XS_FIREWALL_MODE=external|off` его не прикрывает ничто. Оркестратор
* такой конфиг не создаёт, значит он появился правкой руками — и диагностический
* экран обязан это назвать.
*
* Пустой хост (`:36712`) относится сюда же: в Go это тот же wildcard, а не
* loopback.
*/
const trafficStatsIsLoopback = computed(() => {
type TrafficStatsListenState =
| "canonical"
| "wildcard"
| "nonCanonicalLoopback"
| "unreachable";
const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
const trafficStatsListenState = computed<TrafficStatsListenState>(() => {
const listen = profile.value.trafficStats?.listen;
if (!listen) {
return true;
// Секции нет вовсе — об этом сказано отдельной плашкой выше, и второе
// сообщение о том же было бы шумом.
return "canonical";
}
const host = listen.slice(0, listen.lastIndexOf(":"));
return host === "" || host === "127.0.0.1" || host === "0.0.0.0";
const separator = listen.lastIndexOf(":");
const host = separator < 0 ? listen : listen.slice(0, separator);
if (host === "127.0.0.1") {
return "canonical";
}
if (host === "" || host === "0.0.0.0") {
return "wildcard";
}
if (LOOPBACK_IPV4.test(host)) {
return "nonCanonicalLoopback";
}
return "unreachable";
});
const handleExport = async () => {
@@ -523,4 +576,10 @@ onMounted(() => {
font-size: 12px;
color: var(--el-color-danger);
}
.value-warning {
margin-left: 8px;
font-size: 12px;
color: var(--el-color-warning);
}
</style>