diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf11be..a089bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -343,6 +343,17 @@ Hysteria-интеграции с официальной документацие пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя переменной берётся из самого присваивания. +- **Панель считала wildcard нормальным адресом control plane.** Признак на + странице конфигурации отвечал только на вопрос «достучится ли админка», + поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats + API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации + не создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический + loopback и недостижимый адрес; backend при этом по-прежнему отвечает только на + вопрос достижимости — превращать лишнюю публикацию в отказ обслуживания + значило бы отключить всех пиров. Заодно исправлено ложное утверждение в + комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это все + интерфейсы, а не loopback. + ### Исправлено — гейты сборки - **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 6165b4f..4d9388d 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -100,9 +100,9 @@ export default { save: "Save", update: "Update", downloadSuccess: "Download successful", - wait: "The version is being changed, please wait a moment", - enableSuccess: "Hysteria2 start successful", - disableSuccess: "Hysteria2 stop successful", + // `wait`, `enableSuccess` and `disableSuccess` are gone together with the + // operations the panel does not have: starting, stopping and changing the + // version of Hysteria. That lifecycle belongs to systemd and the orchestrator. success: "Success", refresh: "Refresh", yes: "Yes", @@ -335,7 +335,11 @@ export default { trafficStatsMissing: "Traffic Stats API is not configured", trafficStatsMissingHint: "Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.", - trafficStatsNotLoopback: - "the address is not loopback: the panel reaches the Traffic Stats API over 127.0.0.1 only", + 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", + 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 b23547f..17018da 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -100,9 +100,9 @@ export default { save: "Сохранить", update: "Обновить", downloadSuccess: "Загрузка завершена", - wait: "Версия меняется, подождите", - enableSuccess: "Hysteria2 запущена", - disableSuccess: "Hysteria2 остановлена", + // Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с + // операциями, которых у панели нет: запуском, остановкой и сменой версии + // Hysteria. Жизненный цикл принадлежит systemd и оркестратору. success: "Готово", refresh: "Обновить", yes: "Да", @@ -346,7 +346,14 @@ export default { trafficStatsMissing: "Traffic Stats API не настроен", trafficStatsMissingHint: "Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.", - trafficStatsNotLoopback: - "адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1", + // Три разные новости об одном поле, и объединять их нельзя: первая + // означает, что доступ пиров уже не работает, вторая — что внутренний API + // опубликован шире необходимого, третья — просто дрейф от профиля. + trafficStatsUnreachable: + "панель не может обратиться по этому адресу: Traffic Stats API запрашивается только по loopback. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают", + 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 4a997e5..7095ce5 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -308,10 +308,25 @@ {{ text(profile.trafficStats.listen) }} + {{ $t("hysteria.trafficStatsNotLoopback") }}{{ $t("hysteria.trafficStatsUnreachable") }} + {{ $t("hysteria.trafficStatsWildcard") }} + {{ $t("hysteria.trafficStatsNonCanonical") }} @@ -424,19 +439,57 @@ function list(values: string[]): string { } /** - * Traffic Stats API обязан слушать loopback. + * Состояние адреса Traffic Stats API. * - * Админка обращается к нему только по 127.0.0.1, поэтому любой другой адрес - * означает, что Hysteria и панель говорят мимо друг друга: лимит устройств, - * учёт трафика и принудительное отключение перестают работать разом. + * Вопросов здесь ДВА, и смешивать их нельзя. + * + * Первый — технический: достанет ли админка до этого адреса. Она обращается к + * API только по loopback, поэтому LAN- или публичный адрес означает, что + * Hysteria и панель говорят мимо друг друга, и лимит устройств, учёт трафика и + * принудительное отключение перестают работать разом. + * + * Второй — профильный: тот ли это адрес, который создаёт оркестратор. У + * production-профиля он ровно один — `127.0.0.1`. + * + * Прежняя версия отвечала на первый вопрос и молчала по второму: `0.0.0.0` + * считался нормой, потому что через него админка достучится. Но wildcard — не + * loopback: внутренний control plane при нём опубликован на всех интерфейсах, и + * при `HY2XS_FIREWALL_MODE=external|off` его не прикрывает ничто. Оркестратор + * такой конфиг не создаёт, значит он появился правкой руками — и диагностический + * экран обязан это назвать. + * + * Пустой хост (`:36712`) относится сюда же: в Go это тот же wildcard, а не + * loopback. */ -const trafficStatsIsLoopback = computed(() => { +type TrafficStatsListenState = + | "canonical" + | "wildcard" + | "nonCanonicalLoopback" + | "unreachable"; + +const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +const trafficStatsListenState = computed(() => { const listen = profile.value.trafficStats?.listen; if (!listen) { - return true; + // Секции нет вовсе — об этом сказано отдельной плашкой выше, и второе + // сообщение о том же было бы шумом. + return "canonical"; } - const host = listen.slice(0, listen.lastIndexOf(":")); - return host === "" || host === "127.0.0.1" || host === "0.0.0.0"; + + const separator = listen.lastIndexOf(":"); + const host = separator < 0 ? listen : listen.slice(0, separator); + + if (host === "127.0.0.1") { + return "canonical"; + } + if (host === "" || host === "0.0.0.0") { + return "wildcard"; + } + if (LOOPBACK_IPV4.test(host)) { + return "nonCanonicalLoopback"; + } + return "unreachable"; }); const handleExport = async () => { @@ -523,4 +576,10 @@ onMounted(() => { font-size: 12px; color: var(--el-color-danger); } + +.value-warning { + margin-left: 8px; + font-size: 12px; + color: var(--el-color-warning); +} diff --git a/apps/service/config.go b/apps/service/config.go index 100824a..6a969d7 100644 --- a/apps/service/config.go +++ b/apps/service/config.go @@ -135,23 +135,32 @@ func GetHysteria2ApiPort() (int64, error) { // это и есть тот самый второй контракт: значение, которое админка приняла и // проигнорировала. // +// Вопрос здесь ровно один: ДОСТУЧИТСЯ ли админка по этому адресу. Соответствие +// production-профилю — вопрос другой и решается там, где конфиг создаётся +// (validateRuntimeConfig) и где он показывается оператору (страница +// конфигурации отдельно называет wildcard и не-канонический loopback). +// // Что принимается: // -// ":36712" -> хост не задан, Hysteria слушает loopback; // "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 включает loopback, обмен состоится; +// ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ +// интерфейсы, а не loopback. // // Wildcard принимается именно потому, что связь между компонентами при нём // РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API -// значило бы наказывать за неё отключением всех пиров. Запрет на wildcard — -// решение оркестратора, который конфиг создаёт (см. validateRuntimeConfig). +// значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация +// при этом не остаётся незамеченной — о ней сообщает панель. func parseTrafficStatsPort(listen string) (int64, error) { trimmed := strings.TrimSpace(listen) if trimmed == "" { return 0, errors.New("empty listen") } + // Пустой хост подставляется только затем, чтобы SplitHostPort принял + // строку: сам по себе `:36712` означает все интерфейсы, и loopback среди + // них, поэтому подстановка не меняет ответа на вопрос о достижимости. hostPort := trimmed if strings.HasPrefix(trimmed, ":") { hostPort = "127.0.0.1" + trimmed diff --git a/apps/service/config_traffic_stats_test.go b/apps/service/config_traffic_stats_test.go index 6628808..3e7dda2 100644 --- a/apps/service/config_traffic_stats_test.go +++ b/apps/service/config_traffic_stats_test.go @@ -24,14 +24,16 @@ func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) { want int64 }{ {"канонический профиль", "127.0.0.1:36712", 36712}, - {"хост не задан", ":36712", 36712}, {"пробелы по краям", " 127.0.0.1:36712 ", 36712}, {"другой адрес loopback", "127.0.0.5:36712", 36712}, // Wildcard включает loopback: обмен состоится, и отказывать в // обслуживании из-за лишней публикации внутреннего API нельзя — // это отключило бы всех пиров. Запрет на wildcard принадлежит - // оркестратору, который конфиг и создаёт. + // оркестратору, который конфиг и создаёт, а сообщает о нём панель. {"wildcard", "0.0.0.0:36712", 36712}, + // Пустой хост — тот же wildcard, а не loopback: в Go `:36712` + // означает все интерфейсы. + {"хост не задан", ":36712", 36712}, } for _, tc := range cases { 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 1cfb553..2f14fba 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 @@ -32,6 +32,7 @@ rc3](2026-09-02-v1.0.0-rc3-preflight-findings.md) и сверка Hysteria-ин | ADM-HY2-08 | Мёртвые остатки прежней архитектуры | P4 | закрыт | | GATE-03 | Гейт освобождения admission-замка проверял форму, а не замок | P3 | закрыт | | DOC-03 | Отчёт rc3 не называл, к какому дереву относятся выводы | P3 | закрыт | +| DIAG-01 | Панель считала wildcard нормальным состоянием control plane | P3 | закрыт | ROT-01 и ADM-HY2-01…08 пришли внешним разбором; уточнения ниже — при проверке его выводов по коду и исходникам upstream. Три из них меняли предложенное @@ -149,6 +150,43 @@ LAN, публичный адрес), `apps/service/config_traffic_stats_test.go` --- +## DIAG-01 — панель считала wildcard нормальным состоянием control plane + +Найдено при перепроверке фикса ADM-HY2-01 и относится только к диагностике: на +data plane не влияет. + +Первая версия признака на странице конфигурации отвечала на вопрос +«достучится ли админка» и молчала по второму, не менее важному, — «тот ли это +адрес, который создаёт оркестратор»: + +```ts +host === "" || host === "127.0.0.1" || host === "0.0.0.0" +``` + +`0.0.0.0` — не loopback, а wildcard: внутренний control plane при нём +опубликован на всех интерфейсах, и при `HY2XS_FIREWALL_MODE=external|off` его не +прикрывает ничто. Оркестратор такой конфигурации не создаёт, значит она +появилась правкой руками — и диагностический экран обязан это назвать, а не +показывать как норму. Официальная документация Traffic Stats API отдельно +предупреждает об ограничении доступа к этому listener'у. + +Там же обнаружилась вторая неточность, уже в моей формулировке: **пустой хост +(`:36712`) — тоже wildcard, а не loopback.** В Go `:port` означает все +интерфейсы; комментарий в `parseTrafficStatsPort` утверждал обратное. На +поведение это не влияло (wildcard включает loopback, поэтому обмен состоится), +но описание контракта было ложным и исправлено. + +**Как закрыто.** Backend не тронут: он по-прежнему отвечает ровно на вопрос +достижимости, и превращать лишнюю публикацию в отказ обслуживания нельзя — это +отключило бы всех пиров. Разделены понятия на стороне панели: + +| адрес | состояние | что показано | +| --- | --- | --- | +| `127.0.0.1` | `canonical` | без пометки | +| `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы | +| `127.0.0.x` | `nonCanonicalLoopback` | предупреждение: доступен, но оркестратор такого не создаёт | +| прочее | `unreachable` | ошибка: доступ пиров уже не работает | + ## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой Юнит запускает Hysteria с `HYSTERIA_LOG_FORMAT=json`. Разбор складывал запись @@ -370,4 +408,7 @@ go test -race ./service/... -count=1 6. открыть страницу журнала Hysteria: записи разобраны, контекст виден, секретов нет; 7. добавить в конфиг секцию вне профиля (например `resolver`) и открыть страницу - конфигурации: секция обязана попасть в «расхождение конфигурации». + конфигурации: секция обязана попасть в «расхождение конфигурации»; +8. заменить `trafficStats.listen` на `0.0.0.0:36712`: продукт продолжает + работать, а страница конфигурации показывает предупреждение о публикации на + всех интерфейсах. diff --git a/docs/operations/12-operations-and-troubleshooting.md b/docs/operations/12-operations-and-troubleshooting.md index eb71321..b7ee7da 100644 --- a/docs/operations/12-operations-and-troubleshooting.md +++ b/docs/operations/12-operations-and-troubleshooting.md @@ -378,7 +378,18 @@ trafficStats.listen слушает 192.168.1.10, а админка обраща API только по loopback. ...Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure` ``` -Страница конфигурации показывает тот же адрес и помечает его как не-loopback. +Страница конфигурации показывает тот же адрес и называет его состояние. Ответов +три, и они означают разное: + +| адрес | что показано | что это значит | +| --- | --- | --- | +| `127.0.0.1:36712` | без пометки | канон production-профиля | +| `0.0.0.0:36712`, `:36712` | предупреждение | API достижим, но опубликован на всех интерфейсах; при `HY2XS_FIREWALL_MODE=external\|off` его не прикрывает ничто | +| `127.0.0.5:36712` | предупреждение | достижим, но оркестратор такого не создаёт — конфиг правили руками | +| `192.168.1.10:36712` | ошибка | панель до него не достучится, доступ пиров уже не работает | + +Пустой хост в `listen` — это не loopback: в Go `:36712` означает все интерфейсы, +ровно как `0.0.0.0`. ### Дашборд показывает «состояние службы неизвестно» diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index 47d55aa..0b948f2 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 +30. `trafficStats.listen` слушает `127.0.0.1`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback. Страница конфигурации различает три состояния адреса: канон профиля, достижимый но опубликованный шире необходимого (`0.0.0.0`, пустой хост, прочий loopback) и недостижимый 31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет 32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd 33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 9ae30d4..2e9d3bf 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -543,7 +543,37 @@ describe("страница конфигурации Hysteria", () => { const source = configPage(); expect(source).toContain("hysteria.trafficStatsMissing"); - expect(source).toContain("trafficStatsIsLoopback"); + expect(source).toContain("trafficStatsListenState"); + }); + + // Достижимость и соответствие профилю — РАЗНЫЕ вопросы. + // + // Первая версия отвечала только на первый: `0.0.0.0` считался нормой, потому + // что через wildcard админка достучится. Но wildcard — не loopback: + // внутренний control plane при нём опубликован на всех интерфейсах, и при + // `HY2XS_FIREWALL_MODE=external|off` его не прикрывает ничто. Оркестратор + // такой конфигурации не создаёт, значит она появилась правкой руками — и + // диагностический экран обязан это назвать. + test("адрес Traffic Stats API различает достижимость и профиль", () => { + const source = vueCodeOf(configPage()); + + // Канон профиля ровно один, и он назван литералом. + expect(source).toContain('"127.0.0.1"'); + // Пустой хост — тот же wildcard, что и 0.0.0.0: в Go `:36712` означает все + // интерфейсы, а не loopback. + expect(source).toMatch(/host === "" \|\| host === "0\.0\.0\.0"/); + + for (const state of ["canonical", "wildcard", "nonCanonicalLoopback", "unreachable"]) { + expect(source).toContain(state); + } + + // Три разные новости — три разные фразы, и ни одна не потеряна в словарях. + 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"); + } }); // Read-only экран не имеет права быть щедрее санитизированной выгрузки того