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:
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
|
||||
+28
-16
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user