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
+9 -3
View File
@@ -563,16 +563,22 @@ describe("страница конфигурации Hysteria", () => {
// интерфейсы, а не loopback.
expect(source).toMatch(/host === "" \|\| host === "0\.0\.0\.0"/);
for (const state of ["canonical", "wildcard", "nonCanonicalLoopback", "unreachable"]) {
for (const state of ["canonical", "wildcard", "unreachable"]) {
expect(source).toContain(state);
}
// Три разные новости — три разные фразы, и ни одна не потеряна в словарях.
// Прочие адреса loopback — это НЕ «слегка не тот адрес», а
// недостижимость: слушатель на конкретном адресе принимает соединения
// только на него, поэтому bind 127.0.0.5 + dial 127.0.0.1 даёт connection
// refused. Отдельного мягкого состояния для них быть не должно.
expect(source).not.toContain("nonCanonicalLoopback");
expect(source).not.toMatch(/127\\\.\\d/);
// Две разные новости — две разные фразы, и ни одна не потеряна в словарях.
for (const dictionary of [ru, en] as const) {
const hysteria = (dictionary as any).hysteria ?? {};
expect(typeof hysteria.trafficStatsUnreachable).toBe("string");
expect(typeof hysteria.trafficStatsWildcard).toBe("string");
expect(typeof hysteria.trafficStatsNonCanonical).toBe("string");
}
});