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
+28 -16
View File
@@ -143,8 +143,7 @@ func GetHysteria2ApiPort() (int64, error) {
// Что принимается:
//
// "127.0.0.1:36712" -> канонический production-профиль;
// "127.0.0.5:36712" -> любой loopback достижим по loopback;
// "0.0.0.0:36712" -> wildcard включает loopback, обмен состоится;
// "0.0.0.0:36712" -> wildcard принимает соединение на 127.0.0.1;
// ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ
// интерфейсы, а не loopback.
//
@@ -152,6 +151,24 @@ func GetHysteria2ApiPort() (int64, error) {
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
// значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация
// при этом не остаётся незамеченной — о ней сообщает панель.
//
// Прочие адреса loopback НЕ принимаются, и это не педантизм. Слушатель,
// привязанный к конкретному адресу, принимает соединения только на него:
//
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
//
// (проверено). То есть `ip.IsLoopback()` здесь слишком широк: `127.0.0.5`
// выглядит «локальным», но control plane при нём уже не работает, а молчаливое
// принятие такого адреса означало бы отказ авторизации всем пирам без единой
// подсказки о причине.
//
// IPv6-wildcard (`[::]:36712`) тоже не принимается, хотя на хосте с
// `net.ipv6.bindv6only=0` соединение на 127.0.0.1 он принимает. Причин две:
// HY2XS объявлен IPv4-only (`HY2XS_IPV6_ENABLED` обязан быть false), и
// достижимость такого слушателя зависит от настройки ядра, которой продукт не
// управляет. Отвечать «достучусь» на основании чужого sysctl нельзя, а
// указанное в отказе действие — `reconfigure` — для этой конфигурации всё равно
// верное.
func parseTrafficStatsPort(listen string) (int64, error) {
trimmed := strings.TrimSpace(listen)
if trimmed == "" {
@@ -185,26 +202,21 @@ func parseTrafficStatsPort(listen string) (int64, error) {
// assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому
// адресу — а не «нравится ли он профилю».
//
// Совпадение точное, без `ip.IsLoopback()`: слушатель на конкретном адресе
// принимает соединения только на него, поэтому «какой-нибудь loopback» и
// «достижим по 127.0.0.1» — разные утверждения. Разбор — в комментарии выше.
func assertTrafficStatsHostReachable(host string) error {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
switch strings.TrimSpace(host) {
case "", "127.0.0.1", "0.0.0.0":
return nil
}
ip := net.ParseIP(trimmed)
if ip == nil {
return fmt.Errorf(
"trafficStats.listen указывает хост %q: HY2XS ожидает IP-адрес loopback (127.0.0.1)",
trimmed,
)
}
if ip.IsLoopback() || ip.IsUnspecified() {
return nil
}
return fmt.Errorf(
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API только по loopback. "+
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API строго через 127.0.0.1: "+
"слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. "+
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
trimmed,
strings.TrimSpace(host),
)
}
+70 -4
View File
@@ -1,10 +1,12 @@
package service
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"hy2xs-admin/model/constant"
)
@@ -25,10 +27,9 @@ func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
}{
{"канонический профиль", "127.0.0.1:36712", 36712},
{"пробелы по краям", " 127.0.0.1:36712 ", 36712},
{"другой адрес loopback", "127.0.0.5:36712", 36712},
// Wildcard включает loopback: обмен состоится, и отказывать в
// обслуживании из-за лишней публикации внутреннего API нельзя —
// это отключило бы всех пиров. Запрет на wildcard принадлежит
// Wildcard принимает соединение на 127.0.0.1: обмен состоится, и
// отказывать в обслуживании из-за лишней публикации внутреннего API
// нельзя — это отключило бы всех пиров. Запрет на wildcard принадлежит
// оркестратору, который конфиг и создаёт, а сообщает о нём панель.
{"wildcard", "0.0.0.0:36712", 36712},
// Пустой хост — тот же wildcard, а не loopback: в Go `:36712`
@@ -57,6 +58,20 @@ func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
{"адрес LAN", "192.168.1.10:36712"},
{"публичный адрес", "203.0.113.10:36712"},
{"имя хоста", "hysteria.internal:36712"},
// Главный случай, ради которого проверка стала точной. Слушатель на
// конкретном адресе принимает соединения только на него:
//
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
//
// «Какой-нибудь loopback» и «достижим по 127.0.0.1» — разные
// утверждения, и прежний `ip.IsLoopback()` их смешивал: control plane
// при таком адресе уже не работает, а админка молчала.
{"другой адрес loopback", "127.0.0.5:36712"},
{"loopback-подсеть целиком", "127.1.2.3:36712"},
// IPv6-wildcard на хосте с bindv6only=0 соединение принял бы, но HY2XS
// объявлен IPv4-only, а зависеть в ответе «достучусь» от чужого sysctl
// нельзя.
{"IPv6 wildcard", "[::]:36712"},
{"пустая строка", ""},
{"порт вне диапазона", "127.0.0.1:70000"},
{"без порта", "127.0.0.1"},
@@ -94,3 +109,54 @@ func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
t.Errorf("сообщение не называет способ починки: %q", err.Error())
}
}
// Свойство, на котором держится точность проверки, зафиксировано прямо здесь:
// слушатель на конкретном адресе loopback НЕ принимает соединения на
// 127.0.0.1, а wildcard принимает.
//
// Без этого теста правило «принимаем только 127.0.0.1 и 0.0.0.0» выглядит
// произвольным ужесточением, и следующий читатель имеет все основания вернуть
// `ip.IsLoopback()` обратно.
func TestLoopbackBindIsNotReachableThroughAnotherLoopbackAddress(t *testing.T) {
specific, err := net.Listen("tcp", "127.0.0.5:0")
if err != nil {
t.Skipf("хост не позволяет привязаться к 127.0.0.5: %v", err)
}
defer specific.Close()
_, port, err := net.SplitHostPort(specific.Addr().String())
if err != nil {
t.Fatalf("адрес слушателя не разобран: %v", err)
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), 2*time.Second)
if err == nil {
conn.Close()
t.Fatal("соединение на 127.0.0.1 принято слушателем, привязанным к 127.0.0.5: " +
"правило проверки адреса построено на неверном допущении")
}
// Обратная половина: wildcard такое соединение принимает, поэтому
// отказывать по нему в обслуживании было бы неверно.
wildcard, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatalf("не удалось занять wildcard-порт: %v", err)
}
defer wildcard.Close()
go func() {
accepted, acceptErr := wildcard.Accept()
if acceptErr == nil {
accepted.Close()
}
}()
_, wildcardPort, err := net.SplitHostPort(wildcard.Addr().String())
if err != nil {
t.Fatalf("адрес wildcard-слушателя не разобран: %v", err)
}
conn, err = net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", wildcardPort), 2*time.Second)
if err != nil {
t.Fatalf("wildcard не принял соединение на 127.0.0.1: %v", err)
}
conn.Close()
}