From b9d3c03f8d16b7baa35491c2dc93d3fa21534f21 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 3 Sep 2026 03:53:46 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B=D0=BC=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82?= =?UTF-8?q?=D0=BE=D1=82=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20Traffic=20Stats?= =?UTF-8?q?=20API,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20=D0=B4=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5. Это неверно: слушатель на конкретном адресе принимает соединения только на него, а слой proxy обращается строго к http://127.0.0.1:. 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 в тестах. --- CHANGELOG.md | 22 ++++-- apps/frontend/src/lang/package/en.ts | 4 +- apps/frontend/src/lang/package/ru.ts | 12 +-- .../src/views/hysteria/list/index.vue | 22 ++---- apps/service/config.go | 44 +++++++---- apps/service/config_traffic_stats_test.go | 74 ++++++++++++++++++- ...026-09-02-v1.0.0-rc4-preflight-findings.md | 40 ++++++++-- .../12-operations-and-troubleshooting.md | 17 ++++- docs/testing/11-3-target-and-runtime.md | 2 +- tools/build/lib/acceptance.sh | 31 ++++++++ tools/test/frontend-contract.test.ts | 12 ++- 11 files changed, 218 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a089bcc..a3cf796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -347,12 +347,22 @@ Hysteria-интеграции с официальной документацие странице конфигурации отвечал только на вопрос «достучится ли админка», поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации - не создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический - loopback и недостижимый адрес; backend при этом по-прежнему отвечает только на - вопрос достижимости — превращать лишнюю публикацию в отказ обслуживания - значило бы отключить всех пиров. Заодно исправлено ложное утверждение в - комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это все - интерфейсы, а не loopback. + не создаёт. Теперь показываются три состояния: канон профиля, wildcard + (предупреждение) и недостижимый адрес (ошибка); backend при этом по-прежнему + отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ + обслуживания значило бы отключить всех пиров. Заодно исправлено ложное + утверждение в комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это + все интерфейсы, а не loopback. + +- **«Какой-нибудь loopback» не означает «достижим по 127.0.0.1».** Проверка + принимала любой `ip.IsLoopback()`, то есть считала рабочим и `127.0.0.5`. + Слушатель на конкретном адресе принимает соединения только на него + (`bind 127.0.0.5` + `dial 127.0.0.1` = connection refused, проверено), а слой + proxy обращается строго к `http://127.0.0.1:`: такой адрес выглядел + локальным, ломал контур доступа целиком, и админка об этом молчала. + Принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост; свойство закреплено + тестом с настоящими сокетами, а гейт приёмки запрещает возврат `IsLoopback()` + и требует негативного случая в тестах. ### Исправлено — гейты сборки diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 4d9388d..1dafea7 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -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", }, }; diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 17018da..79db353 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -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 — оркестратор такой конфигурации не создаёт", }, }; diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue index 7095ce5..d52c556 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -323,11 +323,6 @@ class="value-warning" >{{ $t("hysteria.trafficStatsWildcard") }} - {{ $t("hysteria.trafficStatsNonCanonical") }} (() => { const listen = profile.value.trafficStats?.listen; @@ -486,9 +481,6 @@ const trafficStatsListenState = computed(() => { if (host === "" || host === "0.0.0.0") { return "wildcard"; } - if (LOOPBACK_IPV4.test(host)) { - return "nonCanonicalLoopback"; - } return "unreachable"; }); diff --git a/apps/service/config.go b/apps/service/config.go index 6a969d7..a8a8e97 100644 --- a/apps/service/config.go +++ b/apps/service/config.go @@ -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), ) } diff --git a/apps/service/config_traffic_stats_test.go b/apps/service/config_traffic_stats_test.go index 3e7dda2..80a661b 100644 --- a/apps/service/config_traffic_stats_test.go +++ b/apps/service/config_traffic_stats_test.go @@ -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() +} diff --git a/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md b/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md index 2f14fba..46455de 100644 --- a/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md +++ b/docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md @@ -176,16 +176,46 @@ host === "" || host === "127.0.0.1" || host === "0.0.0.0" поведение это не влияло (wildcard включает loopback, поэтому обмен состоится), но описание контракта было ложным и исправлено. -**Как закрыто.** Backend не тронут: он по-прежнему отвечает ровно на вопрос -достижимости, и превращать лишнюю публикацию в отказ обслуживания нельзя — это -отключило бы всех пиров. Разделены понятия на стороне панели: +**Как закрыто.** Панель отвечает на оба вопроса раздельно, backend продолжает +отвечать только на вопрос достижимости — превращать лишнюю публикацию в отказ +обслуживания нельзя, это отключило бы всех пиров. | адрес | состояние | что показано | | --- | --- | --- | | `127.0.0.1` | `canonical` | без пометки | | `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы | -| `127.0.0.x` | `nonCanonicalLoopback` | предупреждение: доступен, но оркестратор такого не создаёт | -| прочее | `unreachable` | ошибка: доступ пиров уже не работает | +| прочее, включая `127.0.0.5` | `unreachable` | ошибка: доступ пиров уже не работает | + +### DIAG-01a — «прочий loopback» был не предупреждением, а отказом + +Первая редакция этого же фикса завела мягкое состояние +`nonCanonicalLoopback` для адресов вида `127.0.0.5`, а backend принимал их +через `ip.IsLoopback()`. **Это неверно, и проверено экспериментом:** + +```text +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 +bind [::]:38714 → dial 127.0.0.1:38714 → connected +``` + +Слушатель на конкретном адресе принимает соединения только на него, а слой +proxy обращается строго к `http://127.0.0.1:`. То есть `127.0.0.5` +выглядит «локальным», но control plane при нём уже не работает — и админка +молчала бы об этом, отказывая при этом всем пирам. + +Проверка стала точной: принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост. +IPv6-wildcard (`[::]`) не принимается, хотя эксперимент показал, что соединение +он принял бы: HY2XS объявлен IPv4-only, а достижимость такого слушателя зависит +от `net.ipv6.bindv6only`, которым продукт не управляет — отвечать «достучусь» на +основании чужого sysctl нельзя, а указанное в отказе `reconfigure` для этой +конфигурации всё равно верное действие. + +Гейт приёмки закрепляет именно семантику: `ip.IsLoopback()` в файле запрещён, +обе принимаемые формы названы литералами, а негативный случай `127.0.0.5` +обязан присутствовать в тестах. Само свойство «bind на конкретный loopback не +принимает соединение на 127.0.0.1» зафиксировано отдельным тестом с настоящими +сокетами — иначе правило выглядит произвольным ужесточением, и следующий +читатель вернёт `IsLoopback()` обратно. ## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой diff --git a/docs/operations/12-operations-and-troubleshooting.md b/docs/operations/12-operations-and-troubleshooting.md index b7ee7da..560804a 100644 --- a/docs/operations/12-operations-and-troubleshooting.md +++ b/docs/operations/12-operations-and-troubleshooting.md @@ -385,11 +385,22 @@ API только по loopback. ...Верните 127.0.0.1 через `hy2xs-or | --- | --- | --- | | `127.0.0.1:36712` | без пометки | канон production-профиля | | `0.0.0.0:36712`, `:36712` | предупреждение | API достижим, но опубликован на всех интерфейсах; при `HY2XS_FIREWALL_MODE=external\|off` его не прикрывает ничто | -| `127.0.0.5:36712` | предупреждение | достижим, но оркестратор такого не создаёт — конфиг правили руками | +| `127.0.0.5:36712` | ошибка | **недостижим**, см. ниже | | `192.168.1.10:36712` | ошибка | панель до него не достучится, доступ пиров уже не работает | -Пустой хост в `listen` — это не loopback: в Go `:36712` означает все интерфейсы, -ровно как `0.0.0.0`. +Две детали, на которых легко ошибиться: + +- пустой хост в `listen` — это **не** loopback: в Go `:36712` означает все + интерфейсы, ровно как `0.0.0.0`; +- другой адрес loopback — это **не** «почти правильно». Слушатель на конкретном + адресе принимает соединения только на него: + + ```text + bind 127.0.0.5:36712 → dial 127.0.0.1:36712 → connection refused + ``` + + Админка обращается к Traffic Stats API строго через `127.0.0.1`, поэтому + `127.0.0.5` ломает контур доступа так же, как LAN-адрес. ### Дашборд показывает «состояние службы неизвестно» diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index 0b948f2..cf51e71 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -47,7 +47,7 @@ 27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд 28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron 29. смена секрета пира меняет его `auth_id`: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда `/kick` прошёл успешно, а соединение зарегистрировалось после него -30. `trafficStats.listen` слушает `127.0.0.1`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback. Страница конфигурации различает три состояния адреса: канон профиля, достижимый но опубликованный шире необходимого (`0.0.0.0`, пустой хост, прочий loopback) и недостижимый +30. `trafficStats.listen` слушает `127.0.0.1`. Админка принимает ровно три формы — `127.0.0.1`, `0.0.0.0` и пустой хост (тот же wildcard), — а любой другой адрес, **включая прочие адреса loopback вроде `127.0.0.5`**, отвергает с явным сообщением: слушатель на конкретном адресе соединения на `127.0.0.1` не принимает. Страница конфигурации показывает три состояния: канон профиля, достижим но опубликован шире необходимого (wildcard), недостижим 31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет 32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd 33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index ba40ca3..0ac7e68 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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` diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 2e9d3bf..f230a81 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -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"); } });