From cb20d8d28fd9ca0ce25f892c379d9f8b363bf40f Mon Sep 17 00:00:00 2001 From: Crimson Date: Wed, 2 Sep 2026 23:24:01 +0500 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=20=D1=83=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=20=D0=B8=D0=B4=D0=B5=D0=BD=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D1=81=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=B8=20=D1=81=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20control=20plane=20=D0=BA=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md --- CHANGELOG.md | 104 ++ README.md | 2 +- apps/cmd/server.go | 10 +- apps/controller/config.go | 25 +- apps/controller/peer.go | 4 +- .../src/api/config/hysteriaViewModel.ts | 312 ---- apps/frontend/src/api/config/index.ts | 8 +- apps/frontend/src/api/config/types.ts | 269 +-- apps/frontend/src/api/dashboard/types.ts | 16 + apps/frontend/src/api/peer/index.ts | 5 +- apps/frontend/src/api/peer/types.ts | 24 +- .../src/components/ImputMultiple/index.vue | 53 - apps/frontend/src/components/MapAdd/index.vue | 51 - .../src/components/UnitSelect/index.vue | 72 - apps/frontend/src/lang/package/en.ts | 240 +-- apps/frontend/src/lang/package/ru.ts | 201 +-- apps/frontend/src/types/components.d.ts | 9 +- apps/frontend/src/utils/byte.ts | 76 +- apps/frontend/src/views/dashboard/index.vue | 69 +- .../list/components/Outbounds/index.vue | 210 --- .../src/views/hysteria/list/index.vue | 1567 +++++------------ apps/frontend/src/views/peer/list/index.vue | 44 +- apps/model/vo/dashboard.go | 17 +- apps/model/vo/hysteria2_profile.go | 132 ++ apps/model/vo/log.go | 8 + apps/model/vo/peer.go | 36 +- apps/service/config.go | 60 +- apps/service/config_traffic_stats_test.go | 94 + apps/service/cron_test.go | 2 +- apps/service/dashboard.go | 18 +- apps/service/hysteria2.go | 110 +- apps/service/hysteria2_api.go | 42 +- apps/service/hysteria2_export.go | 87 +- apps/service/hysteria2_export_test.go | 127 ++ apps/service/hysteria2_profile.go | 205 +++ apps/service/hysteria2_profile_test.go | 321 ++++ apps/service/hysteria2_state_test.go | 174 ++ apps/service/journal.go | 218 ++- apps/service/journal_test.go | 271 +++ apps/service/metrics_collector.go | 11 +- apps/service/peer.go | 120 +- apps/service/peer_access_test.go | 91 +- apps/service/peer_secret.go | 98 ++ apps/service/peer_secret_rotation_test.go | 529 ++++++ apps/util/exec_probe_test.go | 68 + apps/util/linux.go | 36 + apps/util/string.go | 36 - docs/README.md | 2 +- ...026-09-02-v1.0.0-rc3-preflight-findings.md | 9 + ...026-09-02-v1.0.0-rc4-preflight-findings.md | 373 ++++ docs/acceptance/README.md | 1 + docs/admin/04-admin-panel.md | 286 ++- docs/admin/15-ui-contracts.md | 46 +- docs/architecture/03-server-hysteria2.md | 6 + .../12-operations-and-troubleshooting.md | 50 + docs/operations/13-production-runbook.md | 24 + docs/runtime/07-systemd-and-firewall.md | 14 + docs/testing/11-2-builder-layer.md | 95 + docs/testing/11-3-target-and-runtime.md | 6 + docs/testing/11-5-negative-and-matrix.md | 5 + orchestrator/src/config/env.ts | 33 + orchestrator/test/env.test.ts | 56 + package/config/hy2xs.env | 4 + package/systemd/hysteria-server.service | 7 + tools/build/lib/acceptance.sh | 238 ++- tools/test/frontend-contract.test.ts | 128 ++ 66 files changed, 4981 insertions(+), 2684 deletions(-) delete mode 100644 apps/frontend/src/api/config/hysteriaViewModel.ts delete mode 100644 apps/frontend/src/components/ImputMultiple/index.vue delete mode 100644 apps/frontend/src/components/MapAdd/index.vue delete mode 100644 apps/frontend/src/components/UnitSelect/index.vue delete mode 100644 apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue create mode 100644 apps/model/vo/hysteria2_profile.go create mode 100644 apps/service/config_traffic_stats_test.go create mode 100644 apps/service/hysteria2_profile.go create mode 100644 apps/service/hysteria2_profile_test.go create mode 100644 apps/service/hysteria2_state_test.go create mode 100644 apps/service/journal_test.go create mode 100644 apps/service/peer_secret_rotation_test.go create mode 100644 apps/util/exec_probe_test.go delete mode 100644 apps/util/string.go create mode 100644 docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b4397b4..6cf11be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,17 @@ Hysteria-интеграции с официальной документацие Разбор задокументирован в [docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md). +Тринадцатый проход — граница между HY2XS и Hysteria, со сверкой по исходникам +тега `app/v2.12.2`, а не только по документации. Предыдущие два прохода привели +в порядок внутреннюю логику отзыва доступа; здесь закрываются места, где эта +логика соприкасается с чужим компонентом и с оператором: идентичность сессий, +которая не менялась вместе с учётными данными и потому лишала цикл учёта +единственного признака отзыва; адрес Traffic Stats API, у которого было два +несовместимых контракта в одном продукте; формат журнала Hysteria, который +разбирался неверно на каждой строке; и панель, показывавшая как факт то, чего +никто не проверял. Разбор задокументирован в +[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md). + ### Исправлено — правило доступа - **Исчерпанная квота не отключала пира никогда.** Правило доступа @@ -239,6 +250,99 @@ Hysteria-интеграции с официальной документацие картине подключений в том же цикле учёта — единственном месте продукта, где она известна целиком. +### Исправлено — отзыв учётных данных и граница с Hysteria (тринадцатый проход) + +- **Смена секрета не отзывала доступ гарантированно.** Отзыв состоит из двух + шагов, и второй умеет не удаться — сходимость обязан обеспечить цикл учёта. Но + сверять ему было нечем: `auth_id` при смене секрета оставался прежним, поэтому + сессия, установленная по отозванным учётным данным, называлась тем же + значением, что и законная, пир в базе существовал, доступ был открыт, + устройств не больше разрешённого. Признака «установлена по недействительному + секрету» в системе не существовало. + + Хуже того, у этого состояния есть путь **без единой неудачи**. Hysteria + дожидается ответа backend-auth и только после `ok = true` помечает соединение + аутентифицированным и сообщает о нём Traffic Stats API (проверено по + исходникам `app/v2.12.2`), поэтому `/kick`, прошедший успешно, пока + авторизация по старому секрету ещё выполнялась, этого соединения не видит. + Атомарной пары «решение авторизации + регистрация онлайна» upstream API не + даёт, и повторным чтением базы перед ответом окно не закрыть. + + Теперь новое поколение учётных данных получает новый `auth_id`, а `/kick` идёт + по старому: пережившая сессия становится orphan и завершается очередным циклом + учёта — механизмом, который уже существует. Правило действует на обеих дверях + к смене секрета, включая импорт, где случай «прежний `auth_id` + новый секрет» + проходил мимо. Ротация происходит тогда и только тогда, когда меняется + `secret_digest`. Цена названа прямо: трафик доживающей сессии за эти секунды + не приписывается пиру и попадает в потери цикла. + +- **Адрес Traffic Stats API имел два контракта.** Оркестратор принимал любой + IPv4 и честно подставлял его в `trafficStats.listen`, а проверка профиля + сверяла конфиг с тем же значением — все гейты проходили. Админка при этом + берёт из `listen` только порт и всегда идёт на `127.0.0.1`. Валидная по всем + проверкам конфигурация разводила компоненты по разным адресам и выключала + лимит устройств, учёт трафика и принудительное отключение разом: `/online` + недоступен → авторизация fail-closed → не подключается никто. Адрес + зафиксирован на `127.0.0.1`, а админка называет расхождение вместо молчаливой + подстановки loopback. + +- **JSON-журнал Hysteria не разбирался ни одной строкой.** Юнит запускает + Hysteria с `HYSTERIA_LOG_FORMAT=json`, но `time` в этом формате — число, и + притом дробное (`EpochMillisTimeEncoder` делит наносекунды на миллисекунду), + поэтому разбор в структуру со строковым полем падал всегда и уходил в + fallback: панель показывала сырой JSON. Замена типа поля на `int64` не + помогла бы. Разбор ведётся по фактическому формату, а структурный контекст + записи (`addr`, `id`, `error`, `listen`, …) больше не выбрасывается, а + дописывается к сообщению в устойчивом порядке и проходит санитайз. Заодно + перестали теряться записи, у которых journald отдаёт `MESSAGE` массивом байт. + +- **«Служба остановлена» и «состояние неизвестно» были одним значением.** + `util.Exec` выбрасывает вывод команды при ненулевом коде возврата, а + `systemctl is-active` отвечает словом состояния в stdout вместе с кодом 3 — + прочитать его было нечем. Дашборд из-за этого умел утверждать «Hysteria + остановлена» и «Traffic Stats API доступен» одновременно: доступность API + выводилась из того же ответа systemd, а не из обращения к API. Появился + `util.ExecProbe`, состояние службы стало трёхзначным, а доступность API — + независимым фактом. Список пиров при недоступном API отвечает «онлайн + неизвестен», а не «все офлайн». + +- **Страница конфигурации показывала дефолты UI вместо файла.** Ответ сервера + накладывался на полный объект значений по умолчанию, поэтому отсутствующая + секция `trafficStats` показывалась как `:9999`, явное `speedTest: false` + считалось ненастроенным, а `ignoreClientBandwidth` без блока `bandwidth` не + показывался вовсе. Экран, существующий ради диагностики расхождений, эти + расхождения скрывал. Теперь панель показывает записанные значения (отличая + «не задано» от значения) и отдельно перечисляет секции вне production-профиля. + Три редактора, которые ничего не сохраняли, удалены вместе с их компонентами. + +- **Читающий экран отдавал больше секретов, чем выгрузка.** Пароль обфускации, + токены ACME DNS, учётные данные outbound-прокси и masquerade уезжали в + браузер в открытом виде, хотя санитизированный экспорт того же конфига их + вырезает. Теперь вместо значения показывается диагностический факт: «задан» / + «не задан», имена параметров без значений, auth-URL с вырезанным токеном. + +- **Секрет за YAML-якорем покидал сервер.** Санитайзер выгрузки не обрабатывал + `yaml.AliasNode`: значение по ссылке оставалось нетронутым, а объявление + якоря стояло под несекретным именем ключа — секрет уезжал в файл дважды. + Обход идёт по цели ссылки, с защитой от циклов: `yaml.v3` на ссылке, + указывающей на предка, строит действительно циклический граф узлов. + +- **Hysteria больше не проверяет обновления сама.** В сборочном и e2e окружении + `HYSTERIA_DISABLE_UPDATE_CHECK=1` стоял, а в production-юните — нет. Версией + владеет один источник (`versions.env` → сборка → пакет → оркестратор), и + production не имеет права отличаться от тестового окружения. + +- **Удалены мёртвые остатки прежней архитектуры.** `util.CompareVersion` + (лексикографическое сравнение версий без потребителя: `2.10 < 2.9`), + `service.ReleaseHysteria2` (пустая заглушка, вызывавшаяся при завершении + сервиса), `PeerClientConfigVo.QrCode` (второй канал доставки QR, который + панель рисует сама), компонент `UnitSelect` и три функции `utils/byte.ts`. + +- **Гейт освобождения admission-замка проверял форму, а не замок.** + `/defer\s+\w+\(\)/` означало «в функции есть какой-нибудь отложенный вызов» и + пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя + переменной берётся из самого присваивания. + ### Исправлено — гейты сборки - **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не diff --git a/README.md b/README.md index 8a23720..15734bb 100644 --- a/README.md +++ b/README.md @@ -647,7 +647,7 @@ hy2xs-orchestrator status \ | `HY2XS_HYSTERIA_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` | | `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` | | `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` | -| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` | +| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API. Фиксированное значение production‑профиля: админка обращается к нему только по loopback, поэтому любой другой адрес выключает лимит устройств, учёт трафика и принудительное отключение | `127.0.0.1` | | `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` | | `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` | | `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` | diff --git a/apps/cmd/server.go b/apps/cmd/server.go index 504b11b..afa6f52 100644 --- a/apps/cmd/server.go +++ b/apps/cmd/server.go @@ -142,13 +142,17 @@ func classifyServeError(err error) error { return errors.New("start server err") } +// releaseResource закрывает то, чем владеет сам процесс админки. +// +// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой +// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria +// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria +// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего +// не делает, но выглядит освобождением ресурса, хуже отсутствующего. func releaseResource() { if err := dao.CloseSqliteDB(); err != nil { logrus.Errorf("%v", err) } - if err := service.ReleaseHysteria2(); err != nil { - logrus.Errorf("%v", err) - } } func initFile() error { diff --git a/apps/controller/config.go b/apps/controller/config.go index 6171800..724afd5 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -187,13 +187,34 @@ func ListConfig(c *gin.Context) { vo.Success(configVos, c) } +// GetHysteria2Config отдаёт панели конфигурацию в терминах production-профиля. +// +// Что было: `vo.Success(service.GetHysteria2Config(), c)` — внутренняя модель +// серверного конфига сериализовалась в браузер целиком. У этого было два +// следствия. +// +// Первое — секреты. `auth` и `trafficStats.secret` закрыты `json:"-"`, но +// пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные данные +// outbound-прокси и masquerade уезжали в открытом виде. Скачиваемая выгрузка +// того же конфига их вырезает, и читающий экран не имеет права быть щедрее. +// Привилегий это не повышало — маршрут под admin JWT, — но и нужды в этих +// значениях у read-only экрана нет. +// +// Второе — смысл ответа. Модель отдавала «все известные HY2XS поля», а панель +// накладывала их на полный объект дефолтов, поэтому экран показывал не файл, а +// файл, дополненный выдумкой: отсутствующий `trafficStats` превращался в +// `:9999`. Ровно тот дрейф, который экран обязан показывать, он и скрывал. +// +// Теперь ответ описывает профиль явно, отличает «не задано» от значения и +// отдельно перечисляет секции вне профиля. Полный документ доступен +// санитизированной выгрузкой ниже. func GetHysteria2Config(c *gin.Context) { - config, err := service.GetHysteria2Config() + profile, err := service.BuildHysteria2Profile() if err != nil { vo.Fail(err.Error(), c) return } - vo.Success(config, c) + vo.Success(profile, c) } // ExportHysteria2Config отдаёт оператору фактический серверный конфиг. diff --git a/apps/controller/peer.go b/apps/controller/peer.go index fcee49f..24068b6 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -72,12 +72,12 @@ func PagePeer(c *gin.Context) { if err != nil { return } - records, total, err := service.PagePeer(peerPageDto) + records, total, onlineState, err := service.PagePeer(peerPageDto) if err != nil { vo.Fail(err.Error(), c) return } - vo.Success(vo.PeerPageVo{Records: records, Total: total}, c) + vo.Success(vo.PeerPageVo{Records: records, Total: total, OnlineState: onlineState}, c) } func SavePeer(c *gin.Context) { diff --git a/apps/frontend/src/api/config/hysteriaViewModel.ts b/apps/frontend/src/api/config/hysteriaViewModel.ts deleted file mode 100644 index ebb9bd2..0000000 --- a/apps/frontend/src/api/config/hysteriaViewModel.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { - Hysteria2ServerConfig, - Hysteria2ServerConfigOutbound, -} from "./types"; - -/** - * Нормализация конфига Hysteria на границе API. - * - * Зачем этот файл существует. - * - * `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти - * все его секции необязательны — потому что необязательны они и в upstream - * YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`, - * `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`. - * - * Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно. - * Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он - * прав: обращение через возможно отсутствующий объект в рантайме падает. - * Спасало только то, что форма строится merge'ем поверх полного объекта - * значений по умолчанию, то есть инвариант «секция есть всегда» существовал, - * но держался на порядке присваиваний внутри компонента и нигде не был - * выражен типом. - * - * Два способа это закрыть неверны: - * - * `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции - * нет», не отвечая на него, и делает шаблон нечитаемым; - * - * `as any` — выключает ровно ту проверку, ради которой обновлялся - * typechecker. - * - * Здесь выбран третий: одно преобразование на входе. Ответ приходит в - * `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма - * работает с `Hysteria2ServerConfigView`, где присутствие каждой секции — - * свойство типа. Шаблону больше не нужно знать ни одного нюанса - * необязательности upstream-схемы. - * - * Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка - * серверного конфига идёт на backend от исходного YAML и сохраняет поля, о - * которых HY2XS ещё не знает (см. docs/04). View-модель — только для - * отображения, поэтому потеря неизвестных полей здесь безвредна. - */ - -/** - * DeepRequired делает обязательными все поля на всех уровнях. - * - * Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и - * маппинг прошёлся бы по свойствам самого массива. - */ -type DeepRequired = T extends (infer U)[] - ? DeepRequired[] - : T extends object - ? { [K in keyof T]-?: DeepRequired> } - : T; - -/** Конфиг Hysteria в том виде, в котором его показывает панель. */ -export type Hysteria2ServerConfigView = DeepRequired; - -/** Один outbound в том же виде. */ -export type Hysteria2ServerConfigOutboundView = - DeepRequired; - -/** - * Полное значение по умолчанию: каждая секция заполнена. - * - * Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и - * добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь — - * то есть новое поле upstream нельзя молча не отобразить. - */ -export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = { - listen: ":443", - tls: { - cert: "", - key: "", - sniGuard: "", - clientCA: "", - }, - ech: { - keyPath: "", - }, - acme: { - domains: [], - email: "", - ca: "letsencrypt", - listenHost: "0.0.0.0", - dir: "/var/lib/hysteria/acme", - type: "", - http: { - altPort: 8888, - }, - tls: { - altPort: 44333, - }, - dns: { - name: "cloudflare", - config: {}, - }, - disableHTTP: false, - disableTLSALPN: false, - altHTTPPort: 80, - altTLSALPNPort: 443, - }, - obfs: { - type: "gecko", - salamander: { - password: "", - }, - gecko: { - password: "", - minPacketSize: 512, - maxPacketSize: 1200, - }, - }, - quic: { - initStreamReceiveWindow: 8388608, - maxStreamReceiveWindow: 8388608, - initConnReceiveWindow: 20971520, - maxConnReceiveWindow: 20971520, - maxIdleTimeout: "30s", - maxIncomingStreams: 1024, - disablePathMTUDiscovery: false, - disableStatelessReset: false, - }, - bandwidth: { - up: "50 mbps", - down: "50 mbps", - disableLossCompensation: false, - }, - congestion: { - type: "bbr", - bbrProfile: "standard", - }, - ignoreClientBandwidth: false, - speedTest: false, - disableUDP: false, - udpIdleTimeout: "60s", - resolver: { - type: "", - tcp: { - addr: "8.8.8.8:53", - timeout: "4s", - }, - udp: { - addr: "8.8.4.4:53", - timeout: "4s", - }, - tls: { - addr: "1.1.1.1:853", - timeout: "10s", - sni: "cloudflare-dns.com", - insecure: false, - }, - https: { - addr: "1.1.1.1:443", - timeout: "10s", - sni: "cloudflare-dns.com", - insecure: false, - }, - }, - sniff: { - enable: true, - timeout: "2s", - rewriteDomain: false, - tcpPorts: "80,443,8000-9000", - udpPorts: "all", - }, - acl: { - file: "", - inline: [], - geoip: "", - geosite: "", - geoUpdateInterval: "168h", - }, - outbounds: [], - trafficStats: { - listen: ":9999", - }, - masquerade: { - type: "", - file: { - dir: "", - }, - proxy: { - url: "", - rewriteHost: true, - insecure: false, - xForwarded: false, - }, - string: { - content: "hello stupid world", - headers: {}, - statusCode: 200, - }, - listenHTTP: ":80", - listenHTTPS: ":443", - forceHTTPS: true, - }, - mimic: { - enabled: false, - interface: "", - xdpMode: "", - path: "", - extraArgs: [], - }, - realm: { - stunServers: [], - stunTimeout: "", - punchTimeout: "", - heartbeatInterval: "", - insecure: false, - ipMode: "", - portMapping: { - enabled: false, - timeout: "", - lifetime: "", - }, - }, -}; - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Рекурсивное наложение ответа сервера на значение по умолчанию. - * - * `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и - * секция со значением `null` означают одно и то же — «не задано», — и обе - * обязаны оставить значение по умолчанию, а не обнулить поле формы. - * - * Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил - * или outbounds с сервера — это весь список, а не патч к дефолтному. - */ -function mergeInto(target: Record, source: unknown): void { - if (!isPlainObject(source)) { - return; - } - - for (const [key, value] of Object.entries(source)) { - if (value === null || value === undefined) { - continue; - } - if (Array.isArray(value)) { - target[key] = value; - continue; - } - if (isPlainObject(value)) { - const existing = target[key]; - if (!isPlainObject(existing)) { - target[key] = {}; - } - mergeInto(target[key] as Record, value); - continue; - } - target[key] = value; - } -} - -function cloneDefaults(): Hysteria2ServerConfigView { - // structuredClone есть во всех целевых браузерах и, в отличие от - // JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию. - return structuredClone(defaultHysteria2ServerConfigView); -} - -/** - * Приводит ответ сервера к модели, с которой работает форма. - * - * Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же - * состояние, в котором форма находится до первого запроса. - */ -export function normalizeHysteriaViewModel( - raw: Hysteria2ServerConfig | null | undefined -): Hysteria2ServerConfigView { - const view = cloneDefaults(); - mergeInto(view as unknown as Record, raw); - return view; -} - -/** Значение по умолчанию для одного outbound. */ -export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView = - { - name: "", - type: "socks5", - socks5: { - addr: "", - username: "", - password: "", - }, - http: { - url: "", - insecure: false, - }, - direct: { - mode: "auto", - bindIPv4: "", - bindIPv6: "", - bindDevice: "", - fastOpen: false, - }, - }; - -/** - * Тот же приём для одного outbound: список приходит с необязательными - * подблоками, а карточка показывает их как обычные поля. - */ -export function normalizeOutboundViewModel( - raw: Hysteria2ServerConfigOutbound | null | undefined -): Hysteria2ServerConfigOutboundView { - const view = structuredClone(defaultHysteria2ServerConfigOutboundView); - mergeInto(view as unknown as Record, raw); - return view; -} diff --git a/apps/frontend/src/api/config/index.ts b/apps/frontend/src/api/config/index.ts index 7fff998..aa6f3ec 100644 --- a/apps/frontend/src/api/config/index.ts +++ b/apps/frontend/src/api/config/index.ts @@ -4,7 +4,7 @@ import { ConfigsDto, ConfigUpdateDto, ConfigVo, - Hysteria2ServerConfig, + Hysteria2ProfileVo, } from "@/api/config/types"; // Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку: @@ -18,7 +18,11 @@ import { // таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через // listConfigApi, на стороне сервера — по allowlist. -export function getHysteria2ConfigApi(): AxiosPromise { +// Ответ описывает production-профиль, а не всю модель серверного конфига: он +// отличает «не задано» от значения и отдельно перечисляет секции вне профиля. +// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем +// обфускации, токенами ACME DNS и учётными данными outbound-прокси. +export function getHysteria2ConfigApi(): AxiosPromise { return request({ url: "/config/getHysteria2Config", method: "get", diff --git a/apps/frontend/src/api/config/types.ts b/apps/frontend/src/api/config/types.ts index ddff1cb..19a8d9d 100644 --- a/apps/frontend/src/api/config/types.ts +++ b/apps/frontend/src/api/config/types.ts @@ -20,188 +20,103 @@ export interface ConfigUpdateDto { } /** - * Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ. + * Конфигурация Hysteria в терминах production-профиля HY2XS. * - * Необязательность секций здесь не оплошность: ровно так устроен upstream YAML, - * и модель обязана его повторять, иначе она врала бы об ответе сервера. + * Что было. Здесь жил `Hysteria2ServerConfig` — полная модель серверного + * конфига со всеми секциями upstream (resolver, sniff, acl, outbounds, + * masquerade, mimic, realm, ech), а рядом, в `hysteriaViewModel.ts`, — её + * копия с обязательными полями и полный объект значений по умолчанию, поверх + * которого накладывался ответ сервера. * - * Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из - * `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию - * живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они - * выглядели частью протокола, хотя относятся исключительно к отображению. + * Из-за этой конструкции экран отвечал не на тот вопрос. Отсутствующая секция + * `trafficStats` показывалась как `:9999`, отсутствующий блок ACME — как набор + * дефолтов UI, `speedTest: false` и `disableUDP: false` считались + * ненастроенными и прятали свои вкладки. Диагностический экран скрывал ровно + * тот дрейф, ради которого его открывают. + * + * Продуктом является ОДИН профиль: конфиг генерирует оркестратор и сам же + * проверяет соответствие файла профилю. Поэтому панель показывает значения + * профиля так, как они записаны, и отдельно перечисляет секции вне профиля. + * Полный документ по-прежнему доступен санитизированной выгрузкой. + * + * `null` означает «в файле этого нет». Это единственный способ отличить + * отсутствие от значения: `false`, `0` и пустая строка — законные значения. */ -export interface Hysteria2ServerConfig { - listen: string; - tls?: { - cert: string; - key: string; - sniGuard?: string; - clientCA?: string; - }; - ech?: { - keyPath?: string; - }; - acme?: { - domains: string[]; - email: string; - ca: string; - listenHost: string; - dir: string; - type?: string; - http?: { - altPort: number; - }; - tls?: { - altPort: number; - }; - dns?: { - name: string; - config: { [key: string]: string }; - }; - disableHTTP: boolean; - disableTLSALPN: boolean; - altHTTPPort: number; - altTLSALPNPort: number; - }; - obfs?: { - type: string; - salamander?: { - password: string; - }; - gecko?: { - password: string; - minPacketSize?: number; - maxPacketSize?: number; - }; - }; - quic?: { - initStreamReceiveWindow?: number; - maxStreamReceiveWindow?: number; - initConnReceiveWindow?: number; - maxConnReceiveWindow?: number; - maxIdleTimeout?: string; - maxIncomingStreams?: number; - disablePathMTUDiscovery?: boolean; - disableStatelessReset?: boolean; - }; - bandwidth?: { - up: string; - down: string; - disableLossCompensation?: boolean; - }; - congestion?: { - type?: string; - bbrProfile?: string; - }; - ignoreClientBandwidth?: boolean; - speedTest?: boolean; - disableUDP?: boolean; - udpIdleTimeout?: string; - resolver?: { - type: string; - tcp?: { - addr: string; - timeout: string; - }; - udp?: { - addr: string; - timeout: string; - }; - tls?: { - addr: string; - timeout: string; - sni: string; - insecure: boolean; - }; - https?: { - addr: string; - timeout: string; - sni: string; - insecure: boolean; - }; - }; - sniff?: { - enable: boolean; - timeout: string; - rewriteDomain: boolean; - tcpPorts?: string; - udpPorts?: string; - }; - acl?: { - file?: string; - inline?: string[]; - geoip?: string; - geosite?: string; - geoUpdateInterval?: string; - }; - outbounds?: Hysteria2ServerConfigOutbound[]; - trafficStats: { - listen: string; - }; - masquerade?: { - type: string; - file?: { - dir: string; - }; - proxy?: { - url: string; - rewriteHost: boolean; - insecure: boolean; - xForwarded?: boolean; - }; - string?: { - content: string; - headers?: { [key: string]: string }; - statusCode?: number; - }; - listenHTTP?: string; - listenHTTPS?: string; - forceHTTPS?: boolean; - }; - mimic?: { - enabled?: boolean; - interface?: string; - xdpMode?: string; - path?: string; - extraArgs?: string[]; - }; - realm?: { - stunServers?: string[]; - stunTimeout?: string; - punchTimeout?: string; - heartbeatInterval?: string; - insecure?: boolean; - ipMode?: string; - portMapping?: { - enabled?: boolean; - timeout?: string; - lifetime?: string; - }; - }; +export interface Hysteria2ProfileVo { + listen: string | null; + auth: Hysteria2ProfileAuth | null; + tls: Hysteria2ProfileTls | null; + acme: Hysteria2ProfileAcme | null; + obfs: Hysteria2ProfileObfs | null; + bandwidth: Hysteria2ProfileBandwidth | null; + ignoreClientBandwidth: boolean | null; + congestion: Hysteria2ProfileCongestion | null; + quic: Hysteria2ProfileQuic | null; + trafficStats: Hysteria2ProfileTrafficStats | null; + /** Секции файла, которых production-профиль не описывает. */ + drift: string[]; } -export interface Hysteria2ServerConfigOutbound { - name: string; - type: string; - socks5?: { - addr: string; - username?: string; - password?: string; - }; - http?: { - url: string; - insecure: boolean; - }; - direct?: { - mode: string; - bindIPv4?: string; - bindIPv6?: string; - bindDevice?: string; - fastOpen?: boolean; - }; +export interface Hysteria2ProfileAuth { + type: string | null; + /** Адрес backend-auth без machine token: он вырезан на сервере. */ + url: string | null; + insecure: boolean | null; } -export interface Tab { - name: string; - desc: string; +export interface Hysteria2ProfileTls { + cert: string | null; + key: string | null; + sniGuard: string | null; + clientCA: string | null; +} + +export interface Hysteria2ProfileAcme { + domains: string[]; + email: string | null; + ca: string | null; + dir: string | null; + listenHost: string | null; + type: string | null; + dnsProvider: string | null; + /** Имена параметров DNS-провайдера; значения на сервер не отдаются. */ + dnsConfigKeys: string[]; +} + +export interface Hysteria2ProfileObfs { + type: string | null; + /** + * Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и + * выдаётся там, где нужен. Диагностичен только факт, что он задан. + */ + passwordSet: boolean; + minPacketSize: number | null; + maxPacketSize: number | null; +} + +export interface Hysteria2ProfileBandwidth { + up: string | null; + down: string | null; + disableLossCompensation: boolean | null; +} + +export interface Hysteria2ProfileCongestion { + type: string | null; + bbrProfile: string | null; +} + +export interface Hysteria2ProfileQuic { + initStreamReceiveWindow: number | null; + maxStreamReceiveWindow: number | null; + initConnReceiveWindow: number | null; + maxConnReceiveWindow: number | null; + maxIdleTimeout: string | null; + maxIncomingStreams: number | null; + disablePathMTUDiscovery: boolean | null; + disableStatelessReset: boolean | null; +} + +export interface Hysteria2ProfileTrafficStats { + listen: string | null; + secretSet: boolean; } diff --git a/apps/frontend/src/api/dashboard/types.ts b/apps/frontend/src/api/dashboard/types.ts index 4629c11..838b346 100644 --- a/apps/frontend/src/api/dashboard/types.ts +++ b/apps/frontend/src/api/dashboard/types.ts @@ -18,7 +18,23 @@ export interface DashboardSummaryVo { }; hysteria: { version: string; + /** + * Состояние службы по systemd. + * + * `unknown` — это НЕ «остановлена»: `systemctl is-active` может не + * ответить вовсе, и тогда о службе не известно ничего. Пока состояние было + * булевым, второе выдавалось за первое, и оператор шёл перезапускать + * работающий туннель. + */ + serviceState: "active" | "inactive" | "unknown"; + /** Ровно `serviceState === "active"`. */ running: boolean; + /** + * Ответил ли Traffic Stats API на фактическое обращение. + * + * Независим от `serviceState`: раньше выводился из него и мог утверждать + * «API доступен» при «служба остановлена», ни разу не сходив в API. + */ apiReachable: boolean; lastApiError?: string; }; diff --git a/apps/frontend/src/api/peer/index.ts b/apps/frontend/src/api/peer/index.ts index 3dddc4b..6a78889 100644 --- a/apps/frontend/src/api/peer/index.ts +++ b/apps/frontend/src/api/peer/index.ts @@ -4,6 +4,7 @@ import { KickPeerDto, PeerClientConfigVo, PeerPageDto, + PeerPageVo, PeerSaveDto, PeerUpdateDto, PeerVo, @@ -27,7 +28,9 @@ export function savePeerApi(data: PeerSaveDto): AxiosPromise { }); } -export function pagePeerApi(data: PeerPageDto): AxiosPromise> { +// Ответ страницы описан собственным типом, а не общим PageVo: кроме +// записей он несёт признак того, известна ли вообще картина подключений. +export function pagePeerApi(data: PeerPageDto): AxiosPromise { return request({ url: "/peers", method: "get", diff --git a/apps/frontend/src/api/peer/types.ts b/apps/frontend/src/api/peer/types.ts index dde6717..a55eba9 100644 --- a/apps/frontend/src/api/peer/types.ts +++ b/apps/frontend/src/api/peer/types.ts @@ -62,9 +62,31 @@ export type PeerVo = { onlineDevices: number; }; +/** + * Известна ли панели картина подключений прямо сейчас. + * + * Признак относится к ответу целиком, а не к строке: при `unavailable` поле + * `online` в строках не означает ничего. Раньше отказ Traffic Stats API молча + * превращался в «все офлайн» — ответ, уводящий оператора искать проблему у + * пользователей. + */ +export type PeerOnlineState = "ok" | "unavailable"; + +export interface PeerPageVo { + records: PeerVo[]; + total: number; + onlineState: PeerOnlineState; +} + +/** + * Клиентская ссылка пира. + * + * Поля `qrCode` здесь больше нет: QR рисуется в панели из самой ссылки + * (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и вторым + * способом получить то же самое. + */ export interface PeerClientConfigVo { url: string; - qrCode?: string | Uint8Array; } export interface KickPeerDto { diff --git a/apps/frontend/src/components/ImputMultiple/index.vue b/apps/frontend/src/components/ImputMultiple/index.vue deleted file mode 100644 index 28392be..0000000 --- a/apps/frontend/src/components/ImputMultiple/index.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/MapAdd/index.vue b/apps/frontend/src/components/MapAdd/index.vue deleted file mode 100644 index 980cf0b..0000000 --- a/apps/frontend/src/components/MapAdd/index.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/components/UnitSelect/index.vue b/apps/frontend/src/components/UnitSelect/index.vue deleted file mode 100644 index ae5f9d6..0000000 --- a/apps/frontend/src/components/UnitSelect/index.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 2b6fb01..6165b4f 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -44,8 +44,18 @@ export default { download: "Download", upload: "Upload", total: "Total", + serviceStateLabel: "Hysteria service", + serviceState: { + active: "Running", + inactive: "Stopped", + unknown: "State unknown", + }, + trafficApiLabel: "Traffic Stats API", + trafficApiReachable: "Reachable", + trafficApiUnreachable: "Unreachable", security: { hysteriaStopped: "Hysteria2 is stopped", + hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer", trafficApiUnavailable: "Traffic API is unavailable", }, error: { @@ -238,8 +248,11 @@ export default { onlineStatus: "Online Status", online: "Online", offline: "Offline", + onlineUnknown: "Online unknown", + onlineUnavailable: "Live connection state is currently unavailable", + onlineUnavailableHint: + "The Hysteria Traffic Stats API did not answer, so online status and device counts are unknown. Stored peer state in the table is accurate.", device: "Online Devices", - unit: "Unit", loginAt: "Last login time", conAt: "Last connection time", createTime: "Create Time", @@ -274,17 +287,9 @@ export default { resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficWeek: "Run once a week, midnight between Sat/Sun", }, - monitor: { - cpuPercent: "CPU Usage", - memPercent: "Memory Usage", - diskPercent: "Disk Usage", - hysteria2UserTotal: "Number of online users", - hysteria2DeviceTotal: "Number of online devices", - hysteria2Version: "Hysteria2 Version", - hysteria2Running: "Hysteria2 Status", - hysteria2RunningTrue: "Running", - hysteria2RunningFalse: "Stop", - }, + // The `monitor` section is gone together with its only consumer — the + // "Hysteria2 Status" tag on the config page. The service state has three + // values, not two, and its phrases live in `dashboard.serviceState`. log: { numLine: "Number of lines", level: "Level", @@ -305,201 +310,32 @@ export default { notFoundBackHome: "Back to home", }, hysteria: { - enable: "Enable", - disable: "Disable", - addConfigItem: "Add Config Item", hysteria2Version: "Hysteria2 Version", - hysteria2Running: "Hysteria2 Status", - outboundsEmpty: "No outbounds are configured on the server", - listen: "Listen", + ownedByOrchestrator: "The Hysteria configuration is owned by the installer", + ownedByOrchestratorHint: + "The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.", + listen: "Listen address", + auth: "Peer authentication", tls: "TLS", obfs: "Obfuscation", quic: "QUIC parameters", bandwidth: "Bandwidth", congestion: "Congestion control", - speedTest: "Speed Test", - udp: "UDP", - resolver: "Resolver", - sniff: "Protocol Sniffing", - acl: "ACL", - outbounds: "Outbounds", - http: "Traffic Stats API (HTTP)", - masquerade: "Masquerade", - config: { - listen: - "When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.", - tlsType: "TLS type", - tls: { - cert: "The path to the Cert file.", - key: "The path to the Key file.", - sniGuard: - 'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.', - }, - acme: { - domains: "Domains", - email: "Email", - ca: "The CA to use. Can be letsencrypt or zerossl.", - listenHost: - "The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.", - dir: "The directory to store the ACME account key and certificates.", - type: "ACME challenge type. Can be http, tls, or dns.", - http: { - altPort: - "Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)", - }, - tls: { - altPort: - "Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)", - }, - dns: { - name: "DNS provider. For details, refer to ACME DNS Configuration.", - config: "ACME DNS Configuration", - }, - disableHTTP: "Disable HTTP challenge.", - disableTLSALPN: "Disable TLS-ALPN challenge.", - altHTTPPort: - "Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)", - altTLSALPNPort: - "Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)", - }, - obfs: { - type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).", - salamander: { - password: "Replace with a strong password of your choice.", - }, - gecko: { - password: "Replace with a strong password of your choice.", - minPacketSize: - "Minimum QUIC handshake fragment size. HY2XS baseline: 512.", - maxPacketSize: - "Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.", - }, - }, - quic: { - initStreamReceiveWindow: "The initial QUIC stream receive window size.", - maxStreamReceiveWindow: "The maximum QUIC stream receive window size.", - initConnReceiveWindow: - "The initial QUIC connection receive window size.", - maxConnReceiveWindow: - "The maximum QUIC connection receive window size.", - maxIdleTimeout: - "The maximum idle timeout. How long the server will consider the client still connected without any activity.", - maxIncomingStreams: - "The maximum number of concurrent incoming streams.", - disablePathMTUDiscovery: "Disable QUIC path MTU discovery.", - disableStatelessReset: - "Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.", - }, - bandwidth: { - up: "Up", - down: "Down", - disableLossCompensation: - "Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.", - }, - congestion: { - type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.", - bbrProfile: "BBR profile: standard, conservative or aggressive.", - }, - ignoreClientBandwidth: - "When enabled, makes the server to disregard any bandwidth hints set by clients", - speedTest: - "speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.", - disableUDP: - "disableUDP disables UDP forwarding, only allowing TCP connections.", - udpIdleTimeout: - "udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.", - resolver: { - type: "Type", - tcp: { - addr: "The address of the TCP resolver.", - timeout: "The timeout for DNS queries.", - }, - udp: { - addr: "The address of the UDP resolver.", - timeout: "The timeout for DNS queries.", - }, - tls: { - addr: "The address of the TLS resolver.", - timeout: "The timeout for DNS queries.", - sni: "The SNI to use for the TLS resolver.", - insecure: "Disable TLS verification for the TLS resolver.", - }, - https: { - addr: "The address of the HTTPS resolver.", - timeout: "The timeout for DNS queries.", - sni: "The SNI to use for the TLS resolver.", - insecure: "Disable TLS verification for the TLS resolver.", - }, - }, - sniff: { - enable: "Whether to enable protocol sniffing.", - timeout: - "Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.", - rewriteDomain: - "Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.", - tcpPorts: - "List of TCP ports. Only TCP requests on these ports will be sniffed.", - udpPorts: - "List of UDP ports. Only UDP requests on these ports will be sniffed.", - }, - aclType: "ACL type", - acl: { - file: "The path to the ACL file.", - inline: "The list of inline ACL rules.", - geoip: - "Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.", - geosite: - "Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.", - geoUpdateInterval: - "Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)", - }, - outbounds: { - name: "The name of the outbound. This is used in ACL rules.", - type: "Type", - socks5: { - addr: "The address of the SOCKS5 proxy.", - username: - "Optional. The username for the SOCKS5 proxy, if authentication is required.", - password: - "Optional. The password for the SOCKS5 proxy, if authentication is required.", - }, - http: { - url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)", - insecure: - "Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.", - }, - direct: { - mode: "Type", - bindIPv4: "The local IPv4 address to bind to.", - bindIPv6: "The local IPv6 address to bind to.", - bindDevice: "The local network interface to bind to.", - fastOpen: "Enable TCP fast open.", - }, - }, - trafficStats: { - listen: "The address to listen on.", - }, - masquerade: { - type: "Type", - file: { - dir: "The directory to serve files from.", - }, - proxy: { - url: "The URL of the website to proxy.", - rewriteHost: - "Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.", - insecure: "Disable TLS verification for the proxied website.", - }, - string: { - content: "The string to return.", - headers: "Optional. The headers to return.", - statusCode: "Optional. The status code to return. 200 by default.", - }, - listenHTTP: "HTTP (TCP) listen address.", - listenHTTPS: "HTTPS (TCP) listen address.", - forceHTTPS: - "Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.", - }, - }, + trafficStats: "Traffic Stats API", + notSet: "not set", + sectionMissing: "The section is absent from the configuration", + tlsMissing: "Neither tls nor acme is configured", + valuesHidden: "values are not shown", + secretSet: "set", + secretMissing: "not set", + obfsPasswordHint: "handed out in the peer share link", + driftTitle: "Configuration drift", + driftHint: + "The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.", + 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", }, }; diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index e1f54b9..b23547f 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -43,8 +43,21 @@ export default { download: "Скачано", upload: "Отдано", total: "Всего", + serviceStateLabel: "Служба Hysteria", + // Три состояния, а не два: «спросить systemd не удалось» — это не + // «служба остановлена», и действия оператора у них разные. + serviceState: { + active: "Работает", + inactive: "Остановлена", + unknown: "Состояние неизвестно", + }, + trafficApiLabel: "Traffic Stats API", + trafficApiReachable: "Доступен", + trafficApiUnreachable: "Недоступен", security: { hysteriaStopped: "Hysteria2 остановлена", + hysteriaStateUnknown: + "Состояние службы Hysteria2 неизвестно: systemd не ответил", trafficApiUnavailable: "Traffic API недоступен", }, error: { @@ -243,13 +256,16 @@ export default { onlineStatus: "Онлайн", online: "Онлайн", offline: "Офлайн", + onlineUnknown: "Онлайн неизвестен", + onlineUnavailable: "Картина подключений сейчас недоступна", + onlineUnavailableHint: + "Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.", device: "Устройства", createdAt: "Создан", bannedUntil: "Блокировка до", totalTraffic: "Суммарный трафик", copyUri: "Копировать URI", more: "Ещё", - unit: "Ед. изм.", loginAt: "Последний вход", conAt: "Последнее подключение", createTime: "Создано", @@ -278,17 +294,10 @@ export default { resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", }, - monitor: { - cpuPercent: "CPU", - memPercent: "Память", - diskPercent: "Диск", - hysteria2UserTotal: "Пользователей онлайн", - hysteria2DeviceTotal: "Устройств онлайн", - hysteria2Version: "Версия Hysteria2", - hysteria2Running: "Статус Hysteria2", - hysteria2RunningTrue: "Работает", - hysteria2RunningFalse: "Остановлена", - }, + // Раздел `monitor` удалён вместе со своим единственным потребителем — + // плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы + // имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`: + // «остановлена» и «неизвестно» — разные ответы оператору. log: { numLine: "Количество строк", level: "Уровень", @@ -309,157 +318,35 @@ export default { notFoundBackHome: "На главную", }, hysteria: { - enable: "Включить", - disable: "Отключить", - addConfigItem: "Добавить параметр", hysteria2Version: "Версия Hysteria2", - hysteria2Running: "Статус Hysteria2", - // addOutbound удалён вместе с редактором outbounds: страница read-only, - // маршрутов записи серверного конфига в API нет. - outboundsEmpty: "Outbounds в конфигурации сервера не заданы", + ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик", + ownedByOrchestratorHint: + "Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.", + // Разделы production-профиля. Экран показывает то, что записано в файле, + // и отдельно называет секции сверх профиля: универсального редактора всех + // возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор. listen: "Адрес прослушивания", + auth: "Авторизация пиров", tls: "TLS", obfs: "Маскировка", quic: "Параметры QUIC", bandwidth: "Полоса", congestion: "Congestion control", - speedTest: "Тест скорости", - udp: "UDP", - resolver: "DNS", - sniff: "Sniffing протоколов", - acl: "ACL", - outbounds: "Outbounds", - http: "Traffic Stats API (HTTP)", - masquerade: "Masquerade", - config: { - listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.", - tlsType: "Тип TLS", - tls: { - cert: "Путь к cert-файлу", - key: "Путь к key-файлу", - sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.", - }, - acme: { - domains: "Домены", - email: "Email", - ca: "CA: letsencrypt или zerossl", - listenHost: "Адрес для ACME challenge", - dir: "Каталог ACME аккаунта и сертификатов", - type: "Тип ACME challenge: http, tls или dns", - http: { altPort: "Альтернативный порт HTTP challenge" }, - tls: { altPort: "Альтернативный порт TLS-ALPN challenge" }, - dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" }, - disableHTTP: "Отключить HTTP challenge", - disableTLSALPN: "Отключить TLS-ALPN challenge", - altHTTPPort: "Альтернативный HTTP-порт", - altTLSALPNPort: "Альтернативный TLS-ALPN-порт", - }, - obfs: { - type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)", - salamander: { password: "Сильный пароль Salamander" }, - gecko: { - password: "Сильный пароль Gecko", - minPacketSize: - "Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512", - maxPacketSize: - "Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048", - }, - }, - quic: { - initStreamReceiveWindow: "Начальное окно приёма QUIC stream", - maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream", - initConnReceiveWindow: "Начальное окно приёма QUIC connection", - maxConnReceiveWindow: "Максимальное окно приёма QUIC connection", - maxIdleTimeout: "Максимальный idle timeout", - maxIncomingStreams: "Максимум входящих stream", - disablePathMTUDiscovery: "Отключить QUIC path MTU discovery", - disableStatelessReset: - "Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства", - }, - bandwidth: { - up: "Вверх", - down: "Вниз", - disableLossCompensation: - "Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает", - }, - congestion: { - type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами", - bbrProfile: "Профиль BBR: standard, conservative или aggressive", - }, - ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом", - speedTest: "Встроенный сервер теста скорости", - disableUDP: "Отключить UDP forwarding", - udpIdleTimeout: "Idle timeout для UDP-сессий", - resolver: { - type: "Тип", - tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" }, - udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" }, - tls: { - addr: "DNS over TLS resolver", - timeout: "Timeout DNS-запросов", - sni: "SNI для TLS resolver", - insecure: "Отключить TLS-проверку", - }, - https: { - addr: "DNS over HTTPS resolver", - timeout: "Timeout DNS-запросов", - sni: "SNI для HTTPS resolver", - insecure: "Отключить TLS-проверку", - }, - }, - sniff: { - enable: "Включить sniffing", - timeout: "Timeout sniffing", - rewriteDomain: "Повторно анализировать доменные запросы", - tcpPorts: "TCP-порты для sniffing", - udpPorts: "UDP-порты для sniffing", - }, - aclType: "Тип ACL", - acl: { - file: "Путь к ACL-файлу", - inline: "Inline ACL-правила", - geoip: "Путь к GeoIP базе", - geosite: "Путь к GeoSite базе", - geoUpdateInterval: "Интервал обновления GeoIP/GeoSite", - }, - outbounds: { - name: "Имя outbound", - type: "Тип", - socks5: { - addr: "Адрес SOCKS5", - username: "Логин SOCKS5", - password: "Пароль SOCKS5", - }, - http: { - url: "URL HTTP/HTTPS proxy", - insecure: "Отключить TLS-проверку proxy", - }, - direct: { - mode: "Тип", - bindIPv4: "Локальный IPv4", - bindIPv6: "Локальный IPv6", - bindDevice: "Сетевой интерфейс", - fastOpen: "TCP fast open", - }, - }, - trafficStats: { listen: "Адрес прослушивания" }, - masquerade: { - type: "Тип", - file: { dir: "Каталог файлов" }, - proxy: { - url: "URL проксируемого сайта", - rewriteHost: "Переписывать Host header", - insecure: "Отключить TLS-проверку", - }, - string: { - content: "Ответ строкой", - headers: "HTTP headers", - statusCode: "HTTP status code", - }, - listenHTTP: "HTTP listen address", - listenHTTPS: "HTTPS listen address", - forceHTTPS: "Принудительно использовать HTTPS", - }, - }, + trafficStats: "Traffic Stats API", + notSet: "не задано", + sectionMissing: "Секция в конфигурации отсутствует", + tlsMissing: "Ни tls, ни acme в конфигурации не заданы", + valuesHidden: "значения не показываются", + secretSet: "задан", + secretMissing: "не задан", + obfsPasswordHint: "выдаётся в клиентской ссылке пира", + driftTitle: "Расхождение конфигурации", + driftHint: + "В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.", + trafficStatsMissing: "Traffic Stats API не настроен", + trafficStatsMissingHint: + "Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.", + trafficStatsNotLoopback: + "адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1", }, }; diff --git a/apps/frontend/src/types/components.d.ts b/apps/frontend/src/types/components.d.ts index 8401055..a4976ef 100644 --- a/apps/frontend/src/types/components.d.ts +++ b/apps/frontend/src/types/components.d.ts @@ -17,6 +17,8 @@ declare module 'vue' { ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] @@ -37,14 +39,12 @@ declare module 'vue' { ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] - ElText: typeof import('element-plus/es')['ElText'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] Hamburger: typeof import('./../components/Hamburger/index.vue')['default'] @@ -53,15 +53,12 @@ declare module 'vue' { IEpMoreFilled: typeof import('~icons/ep/more-filled')['default'] IEpRefresh: typeof import('~icons/ep/refresh')['default'] IEpUserFilled: typeof import('~icons/ep/user-filled')['default'] - ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default'] LangSelect: typeof import('./../components/LangSelect/index.vue')['default'] LogViewer: typeof import('./../components/LogViewer/index.vue')['default'] - MapAdd: typeof import('./../components/MapAdd/index.vue')['default'] Pagination: typeof import('./../components/Pagination/index.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default'] - UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/apps/frontend/src/utils/byte.ts b/apps/frontend/src/utils/byte.ts index c0f41cc..9d3f9fb 100644 --- a/apps/frontend/src/utils/byte.ts +++ b/apps/frontend/src/utils/byte.ts @@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; }; -export const calculateBytes = (value = 0, unit = "Bytes"): number => { - // Приведение единицы к верхнему регистру и удаление пробелов - const formattedUnit = unit.toUpperCase().trim(); - - // Маппинг единиц хранения на количество байт - const unitToBytes: Record = { - BYTES: 1, - KB: 1024 ** 1, - MB: 1024 ** 2, - GB: 1024 ** 3, - TB: 1024 ** 4, - PB: 1024 ** 5, - EB: 1024 ** 6, - ZB: 1024 ** 7, - YB: 1024 ** 8, - }; - - // Проверка наличия единицы в маппинге - if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) { - throw new Error("Invalid unit"); - } - - if (value == -1) { - return -1; - } - - // Расчёт и возврат количества байт - return value * unitToBytes[formattedUnit]; -}; - -/** - * Форматирование единицы хранения - * @param bytes Объём хранения в байтах - * @param decimals Количество знаков после запятой, по умолчанию 2 - * @returns Отформатированное значение объёма хранения - */ -export const formatStorageCapacity = (bytes: number, decimals = 2): number => { - // Проверка корректности входных данных - if (!bytes || bytes <= 0) { - return bytes; - } - - // Расчёт единицы хранения - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - // Форматирование и возврат объёма хранения - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)); -}; - -/** - * Форматирование единицы хранения - * @param bytes Объём хранения в байтах - * @returns Отформатированная единица хранения - */ -export const formatStorageUnit = (bytes: number): string => { - // Проверка корректности входных данных - if (!bytes || bytes <= 0) { - return "Bytes"; - } - - // Расчёт единицы хранения - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - // Возврат отформатированной единицы хранения - return sizes[i]; -}; +// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с +// компонентом UnitSelect, который был их единственным потребителем. +// +// Сам UnitSelect не использовался ни одной страницей панели: квота +// вводится обычным полем в байтах, а показывается через formatBytes. Компонент +// остался от поколения, в котором форма пира предлагала выбор единиц. diff --git a/apps/frontend/src/views/dashboard/index.vue b/apps/frontend/src/views/dashboard/index.vue index 51d4793..1cf8c3d 100644 --- a/apps/frontend/src/views/dashboard/index.vue +++ b/apps/frontend/src/views/dashboard/index.vue @@ -36,6 +36,37 @@ class="mb-2" /> + + + + + {{ summary.hysteria.version || "-" }} + + + + {{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }} + + + + + {{ + summary.hysteria.apiReachable + ? $t("dashboard.trafficApiReachable") + : $t("dashboard.trafficApiUnreachable") + }} + + + + + {{ $t("dashboard.peers") }}: {{ summary.peers.total }} + {{ $t("dashboard.onlinePeers") }}: - {{ summary.peers.onlinePeers }} {{ $t("dashboard.onlineDevices") }}: - {{ summary.peers.onlineDevices }} ({ diskTotalBytes: 0, diskPercent: 0, }, - hysteria: { version: "-", running: false, apiReachable: false }, + // До первого ответа сервера о службе не известно ничего: `inactive` здесь + // было бы утверждением, которого никто не проверял. + hysteria: { + version: "-", + serviceState: "unknown", + running: false, + apiReachable: false, + }, peers: { total: 0, enabled: 0, @@ -231,6 +277,23 @@ const isStale = computed(() => { return Date.now() - lastSuccessAt.value > staleThresholdMs; }); +/** + * Цвет плашки состояния службы. + * + * «Неизвестно» — предупреждение, а не ошибка: это отказ ДИАГНОСТИКИ, и он не + * означает, что туннель не работает. + */ +const serviceStateTagType = computed(() => { + switch (summary.value.hysteria.serviceState) { + case "active": + return "success"; + case "inactive": + return "danger"; + default: + return "warning"; + } +}); + const loadDashboard = async () => { if (loading.value) { return; diff --git a/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue b/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue deleted file mode 100644 index 645c832..0000000 --- a/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue index 4ed12eb..4a997e5 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -14,9 +14,11 @@ @@ -25,1011 +27,308 @@ {{ $t("hysteria.hysteria2Version") }}: - {{ hysteria2Monitor.version ? hysteria2Monitor.version : "-" }} + {{ monitor.version || "-" }} - - {{ $t("hysteria.hysteria2Running") }}: - {{ - hysteria2Monitor.running - ? $t("monitor.hysteria2RunningTrue") - : $t("monitor.hysteria2RunningFalse") - }} + + {{ $t("dashboard.serviceStateLabel") }}: + {{ $t(`dashboard.serviceState.${monitor.serviceState}`) }} - + - - - {{ $t("hysteria.driftHint") }} +
+ {{ section }} +
+
+ + + @@ -1041,123 +340,105 @@ export default { @@ -1236,4 +485,42 @@ onMounted(() => { text-overflow: ellipsis; white-space: nowrap; } + +.drift-alert { + margin-bottom: 16px; +} + +.drift-sections { + margin-top: 6px; +} + +.drift-tag { + margin-right: 6px; +} + +.profile-section { + margin-bottom: 20px; + + h3 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; + } +} + +.value-missing { + color: var(--el-text-color-placeholder); +} + +.value-hint { + margin-left: 8px; + font-size: 12px; + color: var(--el-text-color-secondary); +} + +.value-error { + margin-left: 8px; + font-size: 12px; + color: var(--el-color-danger); +} diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index 4fdbc48..368780c 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -54,6 +54,20 @@ {{ $t("peer.exportBackup") }} + +