fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает

Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5.
Это неверно: слушатель на конкретном адресе принимает соединения только на него,
а слой proxy обращается строго к http://127.0.0.1:<port>.

  bind 127.0.0.5:38712  ->  dial 127.0.0.1:38712  ->  connection refused
  bind 0.0.0.0:38713    ->  dial 127.0.0.1:38713  ->  connected

Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств
fail-closed => не подключается никто) и не вызывал у админки ни одного
возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост.

IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS
объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only
нельзя.

На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий
loopback — это ошибка, а не предупреждение. Осталось три состояния: канон
профиля, wildcard, недостижим.

Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает
возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах.
This commit is contained in:
2026-09-03 03:53:46 +05:00
parent b315001288
commit b9d3c03f8d
11 changed files with 218 additions and 62 deletions
+1 -3
View File
@@ -336,10 +336,8 @@ export default {
trafficStatsMissingHint:
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
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",
"the panel cannot reach this address: the Traffic Stats API is queried through 127.0.0.1 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",
},
};
+6 -6
View File
@@ -346,14 +346,14 @@ export default {
trafficStatsMissing: "Traffic Stats API не настроен",
trafficStatsMissingHint:
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
// Три разные новости об одном поле, и объединять их нельзя: первая
// означает, что доступ пиров уже не работает, вторая — что внутренний API
// опубликован шире необходимого, третья — просто дрейф от профиля.
// Две разные новости об одном поле, и объединять их нельзя: первая
// означает, что доступ пиров уже не работает, вторая — что API работает, но
// опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
// подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
// на 127.0.0.1 не принимает.
trafficStatsUnreachable:
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается только по loopback. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
trafficStatsWildcard:
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
trafficStatsNonCanonical:
"API доступен, но адрес отличается от 127.0.0.1 — оркестратор такой конфигурации не создаёт",
},
};
@@ -323,11 +323,6 @@
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">
<el-tag
@@ -460,14 +455,14 @@ function list(values: string[]): string {
*
* Пустой хост (`:36712`) относится сюда же: в Go это тот же wildcard, а не
* loopback.
*
* Прочие адреса loopback — `127.0.0.5` и подобные — это НЕ «слегка не тот
* адрес», а недостижимость: слушатель на конкретном адресе принимает
* соединения только на него, поэтому `bind 127.0.0.5` + `dial 127.0.0.1` даёт
* connection refused. Панель обязана называть это ошибкой, а не
* предупреждением: control plane при таком адресе уже не работает.
*/
type TrafficStatsListenState =
| "canonical"
| "wildcard"
| "nonCanonicalLoopback"
| "unreachable";
const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
type TrafficStatsListenState = "canonical" | "wildcard" | "unreachable";
const trafficStatsListenState = computed<TrafficStatsListenState>(() => {
const listen = profile.value.trafficStats?.listen;
@@ -486,9 +481,6 @@ const trafficStatsListenState = computed<TrafficStatsListenState>(() => {
if (host === "" || host === "0.0.0.0") {
return "wildcard";
}
if (LOOPBACK_IPV4.test(host)) {
return "nonCanonicalLoopback";
}
return "unreachable";
});