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
+31
View File
@@ -2051,6 +2051,37 @@ run_access_revocation_acceptance() {
# прочитанного адреса, а называет расхождение.
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently"
# Проверяется СЕМАНТИКА, а не наличие функции.
#
# Первая версия принимала любой `ip.IsLoopback()`, и это неверно: слушатель на
# конкретном адресе принимает соединения только на него, поэтому
# `bind 127.0.0.5:36712` + `dial 127.0.0.1:36712` даёт connection refused.
# Такой адрес выглядит «локальным», а control plane при нём уже не работает —
# то есть отказ авторизации всем пирам без единой подсказки о причине.
! code_has apps/service/config.go -F -- 'IsLoopback()' \
|| fail "acceptance: the Traffic Stats host check is back to any loopback address"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/config.go", "utf8");
const start = source.indexOf("func assertTrafficStatsHostReachable");
if (start < 0) throw new Error("assertTrafficStatsHostReachable is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Принимается ровно три формы: канон, IPv4-wildcard и пустой хост (тот же
// wildcard). Всё остальное, включая прочие адреса loopback, недостижимо.
for (const accepted of [`"127.0.0.1"`, `"0.0.0.0"`]) {
if (!body.includes(accepted)) {
throw new Error("the reachable host " + accepted + " is no longer accepted explicitly");
}
}
' || fail "acceptance: the Traffic Stats host must be matched exactly"
# Негативный случай закреплён тестом: без него правило выглядит произвольным
# ужесточением, и следующий читатель вернёт IsLoopback обратно.
code_has apps/service/config_traffic_stats_test.go -F -- '127.0.0.5' \
|| fail "acceptance: the unreachable loopback address is not pinned by a test"
log_step "Acceptance: the systemd state has three values, not two"
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
+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");
}
});