fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `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
This commit is contained in:
+104
@@ -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).
|
[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 срабатывал на корректном коде.** Проверка «авторизация не
|
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не
|
||||||
|
|||||||
@@ -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_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` |
|
||||||
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
|
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
|
||||||
| `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` |
|
| `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_PORT` | Порт trafficStats API | `36712` |
|
||||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
|
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
|
||||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
|
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
|
||||||
|
|||||||
+7
-3
@@ -142,13 +142,17 @@ func classifyServeError(err error) error {
|
|||||||
return errors.New("start server err")
|
return errors.New("start server err")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseResource закрывает то, чем владеет сам процесс админки.
|
||||||
|
//
|
||||||
|
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
|
||||||
|
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
|
||||||
|
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
|
||||||
|
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
|
||||||
|
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
|
||||||
func releaseResource() {
|
func releaseResource() {
|
||||||
if err := dao.CloseSqliteDB(); err != nil {
|
if err := dao.CloseSqliteDB(); err != nil {
|
||||||
logrus.Errorf("%v", err)
|
logrus.Errorf("%v", err)
|
||||||
}
|
}
|
||||||
if err := service.ReleaseHysteria2(); err != nil {
|
|
||||||
logrus.Errorf("%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initFile() error {
|
func initFile() error {
|
||||||
|
|||||||
@@ -187,13 +187,34 @@ func ListConfig(c *gin.Context) {
|
|||||||
vo.Success(configVos, c)
|
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) {
|
func GetHysteria2Config(c *gin.Context) {
|
||||||
config, err := service.GetHysteria2Config()
|
profile, err := service.BuildHysteria2Profile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vo.Success(config, c)
|
vo.Success(profile, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
|
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ func PagePeer(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
records, total, err := service.PagePeer(peerPageDto)
|
records, total, onlineState, err := service.PagePeer(peerPageDto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
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) {
|
func SavePeer(c *gin.Context) {
|
||||||
|
|||||||
@@ -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> = T extends (infer U)[]
|
|
||||||
? DeepRequired<U>[]
|
|
||||||
: T extends object
|
|
||||||
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
|
|
||||||
: T;
|
|
||||||
|
|
||||||
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
|
|
||||||
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
|
|
||||||
|
|
||||||
/** Один outbound в том же виде. */
|
|
||||||
export type Hysteria2ServerConfigOutboundView =
|
|
||||||
DeepRequired<Hysteria2ServerConfigOutbound>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Полное значение по умолчанию: каждая секция заполнена.
|
|
||||||
*
|
|
||||||
* Тип здесь не декоративный. `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<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Рекурсивное наложение ответа сервера на значение по умолчанию.
|
|
||||||
*
|
|
||||||
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
|
|
||||||
* секция со значением `null` означают одно и то же — «не задано», — и обе
|
|
||||||
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
|
|
||||||
*
|
|
||||||
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
|
|
||||||
* или outbounds с сервера — это весь список, а не патч к дефолтному.
|
|
||||||
*/
|
|
||||||
function mergeInto(target: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, raw);
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
ConfigsDto,
|
ConfigsDto,
|
||||||
ConfigUpdateDto,
|
ConfigUpdateDto,
|
||||||
ConfigVo,
|
ConfigVo,
|
||||||
Hysteria2ServerConfig,
|
Hysteria2ProfileVo,
|
||||||
} from "@/api/config/types";
|
} from "@/api/config/types";
|
||||||
|
|
||||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||||
// listConfigApi, на стороне сервера — по allowlist.
|
// listConfigApi, на стороне сервера — по allowlist.
|
||||||
|
|
||||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
// Ответ описывает production-профиль, а не всю модель серверного конфига: он
|
||||||
|
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
|
||||||
|
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
|
||||||
|
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
|
||||||
|
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
|
||||||
return request({
|
return request({
|
||||||
url: "/config/getHysteria2Config",
|
url: "/config/getHysteria2Config",
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -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 {
|
export interface Hysteria2ProfileVo {
|
||||||
listen: string;
|
listen: string | null;
|
||||||
tls?: {
|
auth: Hysteria2ProfileAuth | null;
|
||||||
cert: string;
|
tls: Hysteria2ProfileTls | null;
|
||||||
key: string;
|
acme: Hysteria2ProfileAcme | null;
|
||||||
sniGuard?: string;
|
obfs: Hysteria2ProfileObfs | null;
|
||||||
clientCA?: string;
|
bandwidth: Hysteria2ProfileBandwidth | null;
|
||||||
};
|
ignoreClientBandwidth: boolean | null;
|
||||||
ech?: {
|
congestion: Hysteria2ProfileCongestion | null;
|
||||||
keyPath?: string;
|
quic: Hysteria2ProfileQuic | null;
|
||||||
};
|
trafficStats: Hysteria2ProfileTrafficStats | null;
|
||||||
acme?: {
|
/** Секции файла, которых production-профиль не описывает. */
|
||||||
domains: string[];
|
drift: 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 Hysteria2ServerConfigOutbound {
|
export interface Hysteria2ProfileAuth {
|
||||||
name: string;
|
type: string | null;
|
||||||
type: string;
|
/** Адрес backend-auth без machine token: он вырезан на сервере. */
|
||||||
socks5?: {
|
url: string | null;
|
||||||
addr: string;
|
insecure: boolean | null;
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
http?: {
|
|
||||||
url: string;
|
|
||||||
insecure: boolean;
|
|
||||||
};
|
|
||||||
direct?: {
|
|
||||||
mode: string;
|
|
||||||
bindIPv4?: string;
|
|
||||||
bindIPv6?: string;
|
|
||||||
bindDevice?: string;
|
|
||||||
fastOpen?: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tab {
|
export interface Hysteria2ProfileTls {
|
||||||
name: string;
|
cert: string | null;
|
||||||
desc: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
|
|||||||
};
|
};
|
||||||
hysteria: {
|
hysteria: {
|
||||||
version: string;
|
version: string;
|
||||||
|
/**
|
||||||
|
* Состояние службы по systemd.
|
||||||
|
*
|
||||||
|
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
|
||||||
|
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
|
||||||
|
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
|
||||||
|
* работающий туннель.
|
||||||
|
*/
|
||||||
|
serviceState: "active" | "inactive" | "unknown";
|
||||||
|
/** Ровно `serviceState === "active"`. */
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
/**
|
||||||
|
* Ответил ли Traffic Stats API на фактическое обращение.
|
||||||
|
*
|
||||||
|
* Независим от `serviceState`: раньше выводился из него и мог утверждать
|
||||||
|
* «API доступен» при «служба остановлена», ни разу не сходив в API.
|
||||||
|
*/
|
||||||
apiReachable: boolean;
|
apiReachable: boolean;
|
||||||
lastApiError?: string;
|
lastApiError?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
KickPeerDto,
|
KickPeerDto,
|
||||||
PeerClientConfigVo,
|
PeerClientConfigVo,
|
||||||
PeerPageDto,
|
PeerPageDto,
|
||||||
|
PeerPageVo,
|
||||||
PeerSaveDto,
|
PeerSaveDto,
|
||||||
PeerUpdateDto,
|
PeerUpdateDto,
|
||||||
PeerVo,
|
PeerVo,
|
||||||
@@ -27,7 +28,9 @@ export function savePeerApi(data: PeerSaveDto): AxiosPromise {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
// Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
|
||||||
|
// записей он несёт признак того, известна ли вообще картина подключений.
|
||||||
|
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
|
||||||
return request({
|
return request({
|
||||||
url: "/peers",
|
url: "/peers",
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -62,9 +62,31 @@ export type PeerVo = {
|
|||||||
onlineDevices: number;
|
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 {
|
export interface PeerClientConfigVo {
|
||||||
url: string;
|
url: string;
|
||||||
qrCode?: string | Uint8Array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KickPeerDto {
|
export interface KickPeerDto {
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-tag v-for="item in tags" :key="item" size="large">
|
|
||||||
{{ item }}
|
|
||||||
</el-tag>
|
|
||||||
<el-text v-if="!tags.length" type="info">—</el-text>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { PropType } from "vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Список строковых значений конфигурации: ACME-домены, inline-правила ACL.
|
|
||||||
*
|
|
||||||
* Компонент только показывает список, и это приведение UI в соответствие с тем,
|
|
||||||
* что продукт умеет, а не сокращение возможностей.
|
|
||||||
*
|
|
||||||
* Что было. Здесь жил редактор на `vuedraggable`: перетаскивание тегов,
|
|
||||||
* closable-теги с удалением, поле ввода нового значения и
|
|
||||||
* `emit("update:tags")`. Сохранить он ничего не мог — единственное место
|
|
||||||
* использования — форма конфига Hysteria, отрисованная с `:disabled="true"`,
|
|
||||||
* причём значения передаются как `:tags="dataForm.acme.domains"`, без v-model,
|
|
||||||
* то есть слушателя у события не существует. Маршрутов записи серверного
|
|
||||||
* конфига в API нет.
|
|
||||||
*
|
|
||||||
* Цена этого редактора была измеримой. `vuedraggable` поставляется UMD-сборкой,
|
|
||||||
* поэтому её `require("vue")` разрешался в `vue/dist/vue.cjs.prod.js` — полную
|
|
||||||
* сборку Vue вместе с рантайм-компилятором шаблонов. В бандл уезжало:
|
|
||||||
*
|
|
||||||
* vuedraggable ~155 КБ
|
|
||||||
* sortablejs ~115 КБ
|
|
||||||
* @vue/compiler-core + compiler-dom ~230 КБ
|
|
||||||
*
|
|
||||||
* то есть около полумегабайта исходников ради перетаскивания тегов в форме,
|
|
||||||
* которая недоступна для редактирования.
|
|
||||||
*/
|
|
||||||
const props = defineProps({
|
|
||||||
tags: {
|
|
||||||
required: false,
|
|
||||||
type: Array as PropType<string[]>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tags = computed(() => props.tags ?? []);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.flex.gap-2 {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-tag v-for="entry in entries" :key="entry.key" size="large">
|
|
||||||
{{ entry.key }}: {{ entry.value }}
|
|
||||||
</el-tag>
|
|
||||||
<el-text v-if="!entries.length" type="info">—</el-text>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: "mapObject",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { PropType } from "vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Словарь «ключ — значение» из конфигурации: параметры ACME DNS-провайдера,
|
|
||||||
* заголовки masquerade.
|
|
||||||
*
|
|
||||||
* Как и ImputMultiple, компонент только показывает содержимое. Здесь был
|
|
||||||
* редактор с диалогом добавления, closable-тегами и `emit("update:mapObject")`,
|
|
||||||
* и он ничего не сохранял по тем же трём причинам: форма конфига Hysteria
|
|
||||||
* отрисована с `:disabled="true"`, значения передаются как
|
|
||||||
* `:map-object="dataForm.acme.dns.config"` без v-model, маршрутов записи
|
|
||||||
* серверного конфига в API нет.
|
|
||||||
*
|
|
||||||
* Значение показывается рядом с ключом: прежний вариант прятал его во второй
|
|
||||||
* диалог по клику на тег, что для read-only страницы лишний шаг — увидеть
|
|
||||||
* настройку сервера нужно сразу.
|
|
||||||
*/
|
|
||||||
const props = defineProps({
|
|
||||||
mapObject: {
|
|
||||||
required: false,
|
|
||||||
type: Object as PropType<{ [key: string]: string }>,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = computed(() =>
|
|
||||||
Object.entries(props.mapObject ?? {}).map(([key, value]) => ({ key, value }))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.flex.gap-2 {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div style="display: flex; align-items: center">
|
|
||||||
<el-input-number
|
|
||||||
v-model="capacity"
|
|
||||||
placeholder="Please enter a value"
|
|
||||||
:min="-1"
|
|
||||||
:controls="false"
|
|
||||||
:precision="0"
|
|
||||||
clearable
|
|
||||||
style="width: 220px"
|
|
||||||
/>
|
|
||||||
<el-select
|
|
||||||
v-model="unit"
|
|
||||||
:placeholder="$t('peer.unit')"
|
|
||||||
style="width: 100px"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in units"
|
|
||||||
:key="item"
|
|
||||||
:label="item"
|
|
||||||
:value="item"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { PropType } from "vue";
|
|
||||||
import {
|
|
||||||
calculateBytes,
|
|
||||||
formatStorageCapacity,
|
|
||||||
formatStorageUnit,
|
|
||||||
} from "@/utils/byte";
|
|
||||||
|
|
||||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
valueTmp: {
|
|
||||||
type: Number as PropType<number>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
setValue: {
|
|
||||||
type: Function as PropType<(newValue: number) => void>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
capacity: 0,
|
|
||||||
unit: "GB",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { capacity, unit } = toRefs(state);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[capacity, unit],
|
|
||||||
([newC, newU]) => {
|
|
||||||
const newValue = calculateBytes(newC, newU);
|
|
||||||
props.setValue(newValue);
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.valueTmp,
|
|
||||||
(newValue) => {
|
|
||||||
state.capacity = formatStorageCapacity(newValue);
|
|
||||||
state.unit = formatStorageUnit(newValue);
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
@@ -44,8 +44,18 @@ export default {
|
|||||||
download: "Download",
|
download: "Download",
|
||||||
upload: "Upload",
|
upload: "Upload",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
serviceStateLabel: "Hysteria service",
|
||||||
|
serviceState: {
|
||||||
|
active: "Running",
|
||||||
|
inactive: "Stopped",
|
||||||
|
unknown: "State unknown",
|
||||||
|
},
|
||||||
|
trafficApiLabel: "Traffic Stats API",
|
||||||
|
trafficApiReachable: "Reachable",
|
||||||
|
trafficApiUnreachable: "Unreachable",
|
||||||
security: {
|
security: {
|
||||||
hysteriaStopped: "Hysteria2 is stopped",
|
hysteriaStopped: "Hysteria2 is stopped",
|
||||||
|
hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer",
|
||||||
trafficApiUnavailable: "Traffic API is unavailable",
|
trafficApiUnavailable: "Traffic API is unavailable",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
@@ -238,8 +248,11 @@ export default {
|
|||||||
onlineStatus: "Online Status",
|
onlineStatus: "Online Status",
|
||||||
online: "Online",
|
online: "Online",
|
||||||
offline: "Offline",
|
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",
|
device: "Online Devices",
|
||||||
unit: "Unit",
|
|
||||||
loginAt: "Last login time",
|
loginAt: "Last login time",
|
||||||
conAt: "Last connection time",
|
conAt: "Last connection time",
|
||||||
createTime: "Create Time",
|
createTime: "Create Time",
|
||||||
@@ -274,17 +287,9 @@ export default {
|
|||||||
resetTrafficMonth: "Run once a month, midnight, first of month",
|
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||||
},
|
},
|
||||||
monitor: {
|
// The `monitor` section is gone together with its only consumer — the
|
||||||
cpuPercent: "CPU Usage",
|
// "Hysteria2 Status" tag on the config page. The service state has three
|
||||||
memPercent: "Memory Usage",
|
// values, not two, and its phrases live in `dashboard.serviceState`.
|
||||||
diskPercent: "Disk Usage",
|
|
||||||
hysteria2UserTotal: "Number of online users",
|
|
||||||
hysteria2DeviceTotal: "Number of online devices",
|
|
||||||
hysteria2Version: "Hysteria2 Version",
|
|
||||||
hysteria2Running: "Hysteria2 Status",
|
|
||||||
hysteria2RunningTrue: "Running",
|
|
||||||
hysteria2RunningFalse: "Stop",
|
|
||||||
},
|
|
||||||
log: {
|
log: {
|
||||||
numLine: "Number of lines",
|
numLine: "Number of lines",
|
||||||
level: "Level",
|
level: "Level",
|
||||||
@@ -305,201 +310,32 @@ export default {
|
|||||||
notFoundBackHome: "Back to home",
|
notFoundBackHome: "Back to home",
|
||||||
},
|
},
|
||||||
hysteria: {
|
hysteria: {
|
||||||
enable: "Enable",
|
|
||||||
disable: "Disable",
|
|
||||||
addConfigItem: "Add Config Item",
|
|
||||||
hysteria2Version: "Hysteria2 Version",
|
hysteria2Version: "Hysteria2 Version",
|
||||||
hysteria2Running: "Hysteria2 Status",
|
ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
|
||||||
outboundsEmpty: "No outbounds are configured on the server",
|
ownedByOrchestratorHint:
|
||||||
listen: "Listen",
|
"The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
|
||||||
|
listen: "Listen address",
|
||||||
|
auth: "Peer authentication",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Obfuscation",
|
obfs: "Obfuscation",
|
||||||
quic: "QUIC parameters",
|
quic: "QUIC parameters",
|
||||||
bandwidth: "Bandwidth",
|
bandwidth: "Bandwidth",
|
||||||
congestion: "Congestion control",
|
congestion: "Congestion control",
|
||||||
speedTest: "Speed Test",
|
trafficStats: "Traffic Stats API",
|
||||||
udp: "UDP",
|
notSet: "not set",
|
||||||
resolver: "Resolver",
|
sectionMissing: "The section is absent from the configuration",
|
||||||
sniff: "Protocol Sniffing",
|
tlsMissing: "Neither tls nor acme is configured",
|
||||||
acl: "ACL",
|
valuesHidden: "values are not shown",
|
||||||
outbounds: "Outbounds",
|
secretSet: "set",
|
||||||
http: "Traffic Stats API (HTTP)",
|
secretMissing: "not set",
|
||||||
masquerade: "Masquerade",
|
obfsPasswordHint: "handed out in the peer share link",
|
||||||
config: {
|
driftTitle: "Configuration drift",
|
||||||
listen:
|
driftHint:
|
||||||
"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.",
|
"The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
|
||||||
tlsType: "TLS type",
|
trafficStatsMissing: "Traffic Stats API is not configured",
|
||||||
tls: {
|
trafficStatsMissingHint:
|
||||||
cert: "The path to the Cert file.",
|
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
|
||||||
key: "The path to the Key file.",
|
trafficStatsNotLoopback:
|
||||||
sniGuard:
|
"the address is not loopback: the panel reaches the Traffic Stats API over 127.0.0.1 only",
|
||||||
'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.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,8 +43,21 @@ export default {
|
|||||||
download: "Скачано",
|
download: "Скачано",
|
||||||
upload: "Отдано",
|
upload: "Отдано",
|
||||||
total: "Всего",
|
total: "Всего",
|
||||||
|
serviceStateLabel: "Служба Hysteria",
|
||||||
|
// Три состояния, а не два: «спросить systemd не удалось» — это не
|
||||||
|
// «служба остановлена», и действия оператора у них разные.
|
||||||
|
serviceState: {
|
||||||
|
active: "Работает",
|
||||||
|
inactive: "Остановлена",
|
||||||
|
unknown: "Состояние неизвестно",
|
||||||
|
},
|
||||||
|
trafficApiLabel: "Traffic Stats API",
|
||||||
|
trafficApiReachable: "Доступен",
|
||||||
|
trafficApiUnreachable: "Недоступен",
|
||||||
security: {
|
security: {
|
||||||
hysteriaStopped: "Hysteria2 остановлена",
|
hysteriaStopped: "Hysteria2 остановлена",
|
||||||
|
hysteriaStateUnknown:
|
||||||
|
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
|
||||||
trafficApiUnavailable: "Traffic API недоступен",
|
trafficApiUnavailable: "Traffic API недоступен",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
@@ -243,13 +256,16 @@ export default {
|
|||||||
onlineStatus: "Онлайн",
|
onlineStatus: "Онлайн",
|
||||||
online: "Онлайн",
|
online: "Онлайн",
|
||||||
offline: "Офлайн",
|
offline: "Офлайн",
|
||||||
|
onlineUnknown: "Онлайн неизвестен",
|
||||||
|
onlineUnavailable: "Картина подключений сейчас недоступна",
|
||||||
|
onlineUnavailableHint:
|
||||||
|
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
|
||||||
device: "Устройства",
|
device: "Устройства",
|
||||||
createdAt: "Создан",
|
createdAt: "Создан",
|
||||||
bannedUntil: "Блокировка до",
|
bannedUntil: "Блокировка до",
|
||||||
totalTraffic: "Суммарный трафик",
|
totalTraffic: "Суммарный трафик",
|
||||||
copyUri: "Копировать URI",
|
copyUri: "Копировать URI",
|
||||||
more: "Ещё",
|
more: "Ещё",
|
||||||
unit: "Ед. изм.",
|
|
||||||
loginAt: "Последний вход",
|
loginAt: "Последний вход",
|
||||||
conAt: "Последнее подключение",
|
conAt: "Последнее подключение",
|
||||||
createTime: "Создано",
|
createTime: "Создано",
|
||||||
@@ -278,17 +294,10 @@ export default {
|
|||||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||||
},
|
},
|
||||||
monitor: {
|
// Раздел `monitor` удалён вместе со своим единственным потребителем —
|
||||||
cpuPercent: "CPU",
|
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
|
||||||
memPercent: "Память",
|
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
|
||||||
diskPercent: "Диск",
|
// «остановлена» и «неизвестно» — разные ответы оператору.
|
||||||
hysteria2UserTotal: "Пользователей онлайн",
|
|
||||||
hysteria2DeviceTotal: "Устройств онлайн",
|
|
||||||
hysteria2Version: "Версия Hysteria2",
|
|
||||||
hysteria2Running: "Статус Hysteria2",
|
|
||||||
hysteria2RunningTrue: "Работает",
|
|
||||||
hysteria2RunningFalse: "Остановлена",
|
|
||||||
},
|
|
||||||
log: {
|
log: {
|
||||||
numLine: "Количество строк",
|
numLine: "Количество строк",
|
||||||
level: "Уровень",
|
level: "Уровень",
|
||||||
@@ -309,157 +318,35 @@ export default {
|
|||||||
notFoundBackHome: "На главную",
|
notFoundBackHome: "На главную",
|
||||||
},
|
},
|
||||||
hysteria: {
|
hysteria: {
|
||||||
enable: "Включить",
|
|
||||||
disable: "Отключить",
|
|
||||||
addConfigItem: "Добавить параметр",
|
|
||||||
hysteria2Version: "Версия Hysteria2",
|
hysteria2Version: "Версия Hysteria2",
|
||||||
hysteria2Running: "Статус Hysteria2",
|
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
|
||||||
// addOutbound удалён вместе с редактором outbounds: страница read-only,
|
ownedByOrchestratorHint:
|
||||||
// маршрутов записи серверного конфига в API нет.
|
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
|
||||||
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
|
// Разделы production-профиля. Экран показывает то, что записано в файле,
|
||||||
|
// и отдельно называет секции сверх профиля: универсального редактора всех
|
||||||
|
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
|
||||||
listen: "Адрес прослушивания",
|
listen: "Адрес прослушивания",
|
||||||
|
auth: "Авторизация пиров",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Маскировка",
|
obfs: "Маскировка",
|
||||||
quic: "Параметры QUIC",
|
quic: "Параметры QUIC",
|
||||||
bandwidth: "Полоса",
|
bandwidth: "Полоса",
|
||||||
congestion: "Congestion control",
|
congestion: "Congestion control",
|
||||||
speedTest: "Тест скорости",
|
trafficStats: "Traffic Stats API",
|
||||||
udp: "UDP",
|
notSet: "не задано",
|
||||||
resolver: "DNS",
|
sectionMissing: "Секция в конфигурации отсутствует",
|
||||||
sniff: "Sniffing протоколов",
|
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
|
||||||
acl: "ACL",
|
valuesHidden: "значения не показываются",
|
||||||
outbounds: "Outbounds",
|
secretSet: "задан",
|
||||||
http: "Traffic Stats API (HTTP)",
|
secretMissing: "не задан",
|
||||||
masquerade: "Masquerade",
|
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
|
||||||
config: {
|
driftTitle: "Расхождение конфигурации",
|
||||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
driftHint:
|
||||||
tlsType: "Тип TLS",
|
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
|
||||||
tls: {
|
trafficStatsMissing: "Traffic Stats API не настроен",
|
||||||
cert: "Путь к cert-файлу",
|
trafficStatsMissingHint:
|
||||||
key: "Путь к key-файлу",
|
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
|
||||||
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
|
trafficStatsNotLoopback:
|
||||||
},
|
"адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1",
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-6
@@ -17,6 +17,8 @@ declare module 'vue' {
|
|||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
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']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
@@ -37,14 +39,12 @@ declare module 'vue' {
|
|||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
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']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElText: typeof import('element-plus/es')['ElText']
|
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||||
@@ -53,15 +53,12 @@ declare module 'vue' {
|
|||||||
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
||||||
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['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']
|
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||||
LogViewer: typeof import('./../components/LogViewer/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']
|
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||||
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
|
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
|||||||
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calculateBytes = (value = 0, unit = "Bytes"): number => {
|
// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
|
||||||
// Приведение единицы к верхнему регистру и удаление пробелов
|
// компонентом UnitSelect, который был их единственным потребителем.
|
||||||
const formattedUnit = unit.toUpperCase().trim();
|
//
|
||||||
|
// Сам UnitSelect не использовался ни одной страницей панели: квота
|
||||||
// Маппинг единиц хранения на количество байт
|
// вводится обычным полем в байтах, а показывается через formatBytes. Компонент
|
||||||
const unitToBytes: Record<string, number> = {
|
// остался от поколения, в котором форма пира предлагала выбор единиц.
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -36,6 +36,37 @@
|
|||||||
class="mb-2"
|
class="mb-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Два независимых факта показываются отдельно и называются своими именами.
|
||||||
|
Раньше их было полтора: доступность Traffic Stats API выводилась из
|
||||||
|
ответа systemctl, поэтому дашборд умел утверждать «служба остановлена» и
|
||||||
|
«API доступен» одновременно.
|
||||||
|
-->
|
||||||
|
<el-card shadow="never" class="mt-2">
|
||||||
|
<el-descriptions :column="3" size="small" border>
|
||||||
|
<el-descriptions-item :label="$t('hysteria.hysteria2Version')">
|
||||||
|
{{ summary.hysteria.version || "-" }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('dashboard.serviceStateLabel')">
|
||||||
|
<el-tag size="small" :type="serviceStateTagType">
|
||||||
|
{{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('dashboard.trafficApiLabel')">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="summary.hysteria.apiReachable ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
summary.hysteria.apiReachable
|
||||||
|
? $t("dashboard.trafficApiReachable")
|
||||||
|
: $t("dashboard.trafficApiUnreachable")
|
||||||
|
}}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mt-2">
|
<el-row :gutter="10" class="mt-2">
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
><el-card shadow="never"
|
><el-card shadow="never"
|
||||||
@@ -58,16 +89,24 @@
|
|||||||
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
|
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
|
||||||
></el-col
|
></el-col
|
||||||
>
|
>
|
||||||
|
<!--
|
||||||
|
Пока Traffic Stats API недоступен, «0 онлайн» — не данные, а
|
||||||
|
утверждение, которого никто не проверял. Раньше именно так и было:
|
||||||
|
сборщик метрик получал пустую картину подключений из ответа systemctl и
|
||||||
|
показывал ноль рядом с плашкой «служба остановлена».
|
||||||
|
-->
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
><el-card shadow="never"
|
><el-card shadow="never"
|
||||||
>{{ $t("dashboard.onlinePeers") }}:
|
>{{ $t("dashboard.onlinePeers") }}:
|
||||||
{{ summary.peers.onlinePeers }}</el-card
|
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
|
||||||
></el-col
|
></el-col
|
||||||
>
|
>
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
><el-card shadow="never"
|
><el-card shadow="never"
|
||||||
>{{ $t("dashboard.onlineDevices") }}:
|
>{{ $t("dashboard.onlineDevices") }}:
|
||||||
{{ summary.peers.onlineDevices }}</el-card
|
{{
|
||||||
|
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
|
||||||
|
}}</el-card
|
||||||
></el-col
|
></el-col
|
||||||
>
|
>
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
@@ -183,7 +222,14 @@ const summary = ref<DashboardSummaryVo>({
|
|||||||
diskTotalBytes: 0,
|
diskTotalBytes: 0,
|
||||||
diskPercent: 0,
|
diskPercent: 0,
|
||||||
},
|
},
|
||||||
hysteria: { version: "-", running: false, apiReachable: false },
|
// До первого ответа сервера о службе не известно ничего: `inactive` здесь
|
||||||
|
// было бы утверждением, которого никто не проверял.
|
||||||
|
hysteria: {
|
||||||
|
version: "-",
|
||||||
|
serviceState: "unknown",
|
||||||
|
running: false,
|
||||||
|
apiReachable: false,
|
||||||
|
},
|
||||||
peers: {
|
peers: {
|
||||||
total: 0,
|
total: 0,
|
||||||
enabled: 0,
|
enabled: 0,
|
||||||
@@ -231,6 +277,23 @@ const isStale = computed(() => {
|
|||||||
return Date.now() - lastSuccessAt.value > staleThresholdMs;
|
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 () => {
|
const loadDashboard = async () => {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-tag
|
|
||||||
v-for="item in outbounds"
|
|
||||||
:key="item.name"
|
|
||||||
@click="handleInfo(item)"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
{{ item.name }}
|
|
||||||
</el-tag>
|
|
||||||
|
|
||||||
<el-text v-if="!outbounds.length" type="info">
|
|
||||||
{{ $t("hysteria.outboundsEmpty") }}
|
|
||||||
</el-text>
|
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
:title="outboundInfoDialog.title"
|
|
||||||
v-model="outboundInfoDialog.visible"
|
|
||||||
width="600px"
|
|
||||||
append-to-body
|
|
||||||
@close="outboundInfoDialog.visible = false"
|
|
||||||
>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.name')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="name" prop="name">
|
|
||||||
<el-tag>{{ outboundInfo.name }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.type')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="type" prop="type">
|
|
||||||
<el-tag>{{ outboundInfo.type }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<template v-if="outboundInfo.type === 'socks5'">
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.socks5.addr')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="socks5.addr" prop="socks5.addr">
|
|
||||||
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="socks5.username" prop="socks5.username">
|
|
||||||
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.socks5.password')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="socks5.password" prop="socks5.password">
|
|
||||||
<el-tag>{{ outboundInfo.socks5.password }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
</template>
|
|
||||||
<template v-if="outboundInfo.type === 'http'">
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.http.url')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="http.url" prop="http.url">
|
|
||||||
<el-tag>{{ outboundInfo.http.url }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.http.insecure')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="http.insecure" prop="http.insecure">
|
|
||||||
<el-tag>{{ outboundInfo.http.insecure }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
</template>
|
|
||||||
<template v-if="outboundInfo.type === 'direct'">
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.direct.mode')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="direct.mode" prop="direct.mode">
|
|
||||||
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
|
|
||||||
<el-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
|
|
||||||
<el-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
|
|
||||||
<el-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
|
|
||||||
<el-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
</template>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="outboundInfoDialog.visible = false"
|
|
||||||
>{{ $t("common.close") }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: "outbounds",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
|
|
||||||
import {
|
|
||||||
defaultHysteria2ServerConfigOutboundView,
|
|
||||||
normalizeOutboundViewModel,
|
|
||||||
type Hysteria2ServerConfigOutboundView,
|
|
||||||
} from "@/api/config/hysteriaViewModel";
|
|
||||||
import { PropType } from "vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
// Компонент только показывает outbounds, и это не сужение возможностей, а
|
|
||||||
// приведение UI в соответствие с тем, что продукт умеет.
|
|
||||||
//
|
|
||||||
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
|
|
||||||
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
|
|
||||||
// Ни одно из этих действий не могло ничего сохранить:
|
|
||||||
//
|
|
||||||
// родительская форма отрисована с `:disabled="true"` — страница read-only и
|
|
||||||
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
|
|
||||||
//
|
|
||||||
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
|
|
||||||
// слушателя у `update:outbounds` не существует;
|
|
||||||
//
|
|
||||||
// маршрутов записи серверного конфига в API нет — они удалены вместе с
|
|
||||||
// мёртвым updater/config-write слоем.
|
|
||||||
//
|
|
||||||
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
|
|
||||||
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
|
|
||||||
// перехода на другую вкладку.
|
|
||||||
const props = defineProps({
|
|
||||||
outbounds: {
|
|
||||||
required: true,
|
|
||||||
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
|
|
||||||
default: (): Array<Hysteria2ServerConfigOutbound> => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const outbounds = computed(() => props.outbounds ?? []);
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
outboundInfoDialog: {
|
|
||||||
title: t("hysteria.outbounds"),
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
// Карточка работает с нормализованной моделью по той же причине, что и
|
|
||||||
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
|
|
||||||
// шаблон обращается к их полям напрямую.
|
|
||||||
outboundInfo: {
|
|
||||||
...defaultHysteria2ServerConfigOutboundView,
|
|
||||||
} as Hysteria2ServerConfigOutboundView,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { outboundInfoDialog, outboundInfo } = toRefs(state);
|
|
||||||
|
|
||||||
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
|
||||||
state.outboundInfo = normalizeOutboundViewModel(outbound);
|
|
||||||
state.outboundInfoDialog.visible = true;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.flex.gap-2 {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,20 @@
|
|||||||
{{ $t("peer.exportBackup") }}
|
{{ $t("peer.exportBackup") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
Недоступность Traffic Stats API называется прямо, а не изображается
|
||||||
|
строками «офлайн». Это разные ответы: первый указывает на control
|
||||||
|
plane, второй отправляет оператора искать проблему у пользователей.
|
||||||
|
-->
|
||||||
|
<el-alert
|
||||||
|
v-if="onlineUnknown"
|
||||||
|
:title="$t('peer.onlineUnavailable')"
|
||||||
|
:description="$t('peer.onlineUnavailableHint')"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
<el-table v-loading="loading" :data="records">
|
<el-table v-loading="loading" :data="records">
|
||||||
<el-table-column :label="$t('peer.name')" min-width="220">
|
<el-table-column :label="$t('peer.name')" min-width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -73,6 +87,14 @@
|
|||||||
}}</el-tag
|
}}</el-tag
|
||||||
>
|
>
|
||||||
<el-tag
|
<el-tag
|
||||||
|
v-if="onlineUnknown"
|
||||||
|
size="small"
|
||||||
|
class="ml-1"
|
||||||
|
type="warning"
|
||||||
|
>{{ $t("peer.onlineUnknown") }}</el-tag
|
||||||
|
>
|
||||||
|
<el-tag
|
||||||
|
v-else
|
||||||
size="small"
|
size="small"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
:type="scope.row.online ? 'success' : 'info'"
|
:type="scope.row.online ? 'success' : 'info'"
|
||||||
@@ -102,8 +124,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('peer.devices')" min-width="120">
|
<el-table-column :label="$t('peer.devices')" min-width="120">
|
||||||
|
<!--
|
||||||
|
Число подключённых устройств известно только из Traffic Stats API.
|
||||||
|
Пока он молчит, «0» было бы утверждением, а не данными.
|
||||||
|
-->
|
||||||
<template #default="scope"
|
<template #default="scope"
|
||||||
>{{ scope.row.onlineDevices }} /
|
>{{ onlineUnknown ? "?" : scope.row.onlineDevices }} /
|
||||||
{{ scope.row.maxDevices }}</template
|
{{ scope.row.maxDevices }}</template
|
||||||
>
|
>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -338,7 +364,8 @@
|
|||||||
{{ formatBytes(overview.data.uploadBytes) }}
|
{{ formatBytes(overview.data.uploadBytes) }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>{{ $t("peer.devices") }}:</b> {{ overview.data.onlineDevices }} /
|
<b>{{ $t("peer.devices") }}:</b>
|
||||||
|
{{ onlineUnknown ? "?" : overview.data.onlineDevices }} /
|
||||||
{{ overview.data.maxDevices }}
|
{{ overview.data.maxDevices }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -408,6 +435,7 @@ import {
|
|||||||
exportPeerApi,
|
exportPeerApi,
|
||||||
} from "@/api/peer";
|
} from "@/api/peer";
|
||||||
import {
|
import {
|
||||||
|
PeerOnlineState,
|
||||||
PeerPageDto,
|
PeerPageDto,
|
||||||
PeerSaveDto,
|
PeerSaveDto,
|
||||||
PeerUpdateDto,
|
PeerUpdateDto,
|
||||||
@@ -469,6 +497,14 @@ const { t } = useI18n();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const records = ref<PeerVo[]>([]);
|
const records = ref<PeerVo[]>([]);
|
||||||
|
/**
|
||||||
|
* Известна ли картина подключений.
|
||||||
|
*
|
||||||
|
* До первого ответа считается известной: пустая таблица и без того ничего не
|
||||||
|
* утверждает, а предупреждение на пустом экране только сбивало бы.
|
||||||
|
*/
|
||||||
|
const onlineState = ref<PeerOnlineState>("ok");
|
||||||
|
const onlineUnknown = computed(() => onlineState.value === "unavailable");
|
||||||
const qrDialog = ref(false);
|
const qrDialog = ref(false);
|
||||||
const qrUrl = ref("");
|
const qrUrl = ref("");
|
||||||
const importFileList = ref<UploadFile[]>([]);
|
const importFileList = ref<UploadFile[]>([]);
|
||||||
@@ -649,6 +685,10 @@ async function handleQuery() {
|
|||||||
const { data } = await pagePeerApi(queryParams);
|
const { data } = await pagePeerApi(queryParams);
|
||||||
records.value = data.records;
|
records.value = data.records;
|
||||||
total.value = data.total;
|
total.value = data.total;
|
||||||
|
// Признак приходит один на страницу: недоступность Traffic Stats API —
|
||||||
|
// свойство ответа целиком. При `unavailable` поле `online` в строках не
|
||||||
|
// означает ничего, и показывать его как «офлайн» нельзя.
|
||||||
|
onlineState.value = data.onlineState;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,22 @@ type DashboardSystemVo struct {
|
|||||||
DiskPercent float64 `json:"diskPercent"`
|
DiskPercent float64 `json:"diskPercent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardHysteriaVo — состояние Hysteria из ДВУХ независимых источников.
|
||||||
|
//
|
||||||
|
// `serviceState` отвечает systemd, `apiReachable` — фактическое обращение к
|
||||||
|
// Traffic Stats API. Раньше второе выводилось из первого: сборщик метрик
|
||||||
|
// получал от общего Hysteria2Online пустую карту без ошибки, когда systemctl
|
||||||
|
// говорил «неактивна», и выставлял `apiReachable = true`, ни разу не сходив в
|
||||||
|
// API. Дашборд показывал «служба остановлена» и «API доступен» одновременно.
|
||||||
|
//
|
||||||
|
// `running` сохранён для совместимости отображения и означает ровно
|
||||||
|
// `serviceState == active`. Решения на нём не строятся: «неизвестно» — это не
|
||||||
|
// «остановлена», и путать их продукту уже дорого обходилось.
|
||||||
type DashboardHysteriaVo struct {
|
type DashboardHysteriaVo struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
|
// ServiceState — active | inactive | unknown.
|
||||||
|
ServiceState string `json:"serviceState"`
|
||||||
ApiReachable bool `json:"apiReachable"`
|
ApiReachable bool `json:"apiReachable"`
|
||||||
LastApiError string `json:"lastApiError,omitempty"`
|
LastApiError string `json:"lastApiError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package vo
|
||||||
|
|
||||||
|
// Страница конфигурации показывает ТО, ЧТО НАПИСАНО В ФАЙЛЕ.
|
||||||
|
//
|
||||||
|
// Что было. Панель отдавала браузеру внутреннюю модель серверного конфига
|
||||||
|
// целиком, а frontend накладывал ответ на полный объект значений по умолчанию
|
||||||
|
// (`DeepRequired` + merge). В результате экран отвечал не на вопрос
|
||||||
|
//
|
||||||
|
// что реально написано в /etc/hysteria/config.yaml?
|
||||||
|
//
|
||||||
|
// а на вопрос
|
||||||
|
//
|
||||||
|
// как выглядел бы конфиг, если недостающие куски заполнить дефолтами UI?
|
||||||
|
//
|
||||||
|
// Разница не косметическая. Отсутствующая секция `trafficStats` показывалась
|
||||||
|
// как `:9999`; `speedTest: false` и `disableUDP: false` — валидные явные
|
||||||
|
// значения — считались отсутствующими и прятали свои вкладки;
|
||||||
|
// `ignoreClientBandwidth` (самостоятельная опция верхнего уровня) показывался
|
||||||
|
// только внутри секции bandwidth и при её отсутствии исчезал вместе с ней;
|
||||||
|
// `masquerade.string.statusCode` (число 200..599 у upstream) рисовался
|
||||||
|
// переключателем. То есть экран, существующий ради диагностики расхождений,
|
||||||
|
// эти расхождения скрывал.
|
||||||
|
//
|
||||||
|
// Второе свойство прежнего ответа: он вёз в браузер секреты. `auth` и
|
||||||
|
// `trafficStats.secret` были закрыты `json:"-"`, но пароль обфускации, токены
|
||||||
|
// ACME DNS, учётные данные outbound-прокси и masquerade — нет. Скачиваемый
|
||||||
|
// экспорт того же конфига при этом их вырезает. Читающий экран не имеет права
|
||||||
|
// быть щедрее выгрузки.
|
||||||
|
//
|
||||||
|
// Поэтому ответ описан отдельным типом. Он показывает production-профиль HY2XS
|
||||||
|
// — то, чем реально управляет оркестратор, — и отдельно перечисляет секции,
|
||||||
|
// которых в профиле нет. Полный документ по-прежнему доступен санитизированной
|
||||||
|
// выгрузкой.
|
||||||
|
//
|
||||||
|
// Указатель означает «в файле этого нет». Это единственный способ отличить
|
||||||
|
// отсутствие от значения: `false`, `0` и пустая строка — законные значения.
|
||||||
|
|
||||||
|
// Hysteria2ProfileVo — конфигурация Hysteria в терминах production-профиля.
|
||||||
|
type Hysteria2ProfileVo struct {
|
||||||
|
Listen *string `json:"listen"`
|
||||||
|
Auth *Hysteria2ProfileAuthVo `json:"auth"`
|
||||||
|
Tls *Hysteria2ProfileTlsVo `json:"tls"`
|
||||||
|
Acme *Hysteria2ProfileAcmeVo `json:"acme"`
|
||||||
|
Obfs *Hysteria2ProfileObfsVo `json:"obfs"`
|
||||||
|
Bandwidth *Hysteria2ProfileBandwidthVo `json:"bandwidth"`
|
||||||
|
IgnoreClientBandwidth *bool `json:"ignoreClientBandwidth"`
|
||||||
|
Congestion *Hysteria2ProfileCongestionVo `json:"congestion"`
|
||||||
|
Quic *Hysteria2ProfileQuicVo `json:"quic"`
|
||||||
|
TrafficStats *Hysteria2ProfileTrafficStatsVo `json:"trafficStats"`
|
||||||
|
|
||||||
|
// Drift — секции верхнего уровня, которых production-профиль не описывает.
|
||||||
|
//
|
||||||
|
// Считается по СЫРОМУ YAML, а не по типизированной модели: секция, о
|
||||||
|
// которой HY2XS не знает вовсе, обязана быть замечена именно как
|
||||||
|
// расхождение, а не потеряна при разборе.
|
||||||
|
Drift []string `json:"drift"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hysteria2ProfileAuthVo — способ допуска пиров.
|
||||||
|
//
|
||||||
|
// URL показывается санитизированным: это единственный канал допуска, и знать
|
||||||
|
// его порт и путь оператору нужно, а machine token — нет.
|
||||||
|
type Hysteria2ProfileAuthVo struct {
|
||||||
|
Type *string `json:"type"`
|
||||||
|
Url *string `json:"url"`
|
||||||
|
Insecure *bool `json:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hysteria2ProfileTlsVo struct {
|
||||||
|
Cert *string `json:"cert"`
|
||||||
|
Key *string `json:"key"`
|
||||||
|
SniGuard *string `json:"sniGuard"`
|
||||||
|
ClientCA *string `json:"clientCA"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hysteria2ProfileAcmeVo — выпуск сертификата.
|
||||||
|
//
|
||||||
|
// DnsConfigKeys перечисляет ИМЕНА параметров DNS-провайдера без значений: сам
|
||||||
|
// факт «токен задан» диагностичен, а значение — это ключ от DNS-зоны.
|
||||||
|
type Hysteria2ProfileAcmeVo struct {
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Ca *string `json:"ca"`
|
||||||
|
Dir *string `json:"dir"`
|
||||||
|
ListenHost *string `json:"listenHost"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
DnsProvider *string `json:"dnsProvider"`
|
||||||
|
DnsConfigKeys []string `json:"dnsConfigKeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hysteria2ProfileObfsVo — обфускация.
|
||||||
|
//
|
||||||
|
// Пароль не возвращается: он входит в клиентскую ссылку, и оператор получает
|
||||||
|
// его там, где он нужен. Здесь диагностичен только факт, что пароль задан.
|
||||||
|
type Hysteria2ProfileObfsVo struct {
|
||||||
|
Type *string `json:"type"`
|
||||||
|
PasswordSet bool `json:"passwordSet"`
|
||||||
|
MinPacketSize *int `json:"minPacketSize"`
|
||||||
|
MaxPacketSize *int `json:"maxPacketSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hysteria2ProfileBandwidthVo struct {
|
||||||
|
Up *string `json:"up"`
|
||||||
|
Down *string `json:"down"`
|
||||||
|
DisableLossCompensation *bool `json:"disableLossCompensation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hysteria2ProfileCongestionVo struct {
|
||||||
|
Type *string `json:"type"`
|
||||||
|
BbrProfile *string `json:"bbrProfile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hysteria2ProfileQuicVo struct {
|
||||||
|
InitStreamReceiveWindow *uint64 `json:"initStreamReceiveWindow"`
|
||||||
|
MaxStreamReceiveWindow *uint64 `json:"maxStreamReceiveWindow"`
|
||||||
|
InitConnReceiveWindow *uint64 `json:"initConnReceiveWindow"`
|
||||||
|
MaxConnReceiveWindow *uint64 `json:"maxConnReceiveWindow"`
|
||||||
|
MaxIdleTimeout *string `json:"maxIdleTimeout"`
|
||||||
|
MaxIncomingStreams *int64 `json:"maxIncomingStreams"`
|
||||||
|
DisablePathMTUDiscovery *bool `json:"disablePathMTUDiscovery"`
|
||||||
|
DisableStatelessReset *bool `json:"disableStatelessReset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hysteria2ProfileTrafficStatsVo — внутренний control plane.
|
||||||
|
//
|
||||||
|
// `listen` показывается ровно так, как записан в файле: именно расхождение
|
||||||
|
// этого адреса с loopback выключает лимит устройств, учёт трафика и
|
||||||
|
// принудительное отключение разом, и увидеть его оператор должен здесь.
|
||||||
|
type Hysteria2ProfileTrafficStatsVo struct {
|
||||||
|
Listen *string `json:"listen"`
|
||||||
|
SecretSet bool `json:"secretSet"`
|
||||||
|
}
|
||||||
@@ -23,6 +23,14 @@ type LogSystemVo struct {
|
|||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogHysteria2Vo — строка журнала Hysteria в том виде, в каком её показывает
|
||||||
|
// панель.
|
||||||
|
//
|
||||||
|
// Тип НЕ является формой upstream-записи и никогда не разбирается прямым
|
||||||
|
// json.Unmarshal: JSON-логгер Hysteria 2.12.2 пишет `time` числом
|
||||||
|
// (zapcore.EpochMillisTimeEncoder), и попытка сложить его в строковое поле
|
||||||
|
// роняла разбор целиком. Форма провода живёт в service/journal.go, здесь —
|
||||||
|
// только результат.
|
||||||
type LogHysteria2Vo struct {
|
type LogHysteria2Vo struct {
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
|
|||||||
+34
-2
@@ -18,12 +18,44 @@ type PeerVo struct {
|
|||||||
OnlineDevices int64 `json:"onlineDevices"`
|
OnlineDevices int64 `json:"onlineDevices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerOnlineState — известна ли панели картина подключений прямо сейчас.
|
||||||
|
//
|
||||||
|
// Признак один на всю страницу, а не поле в каждой строке: недоступность
|
||||||
|
// Traffic Stats API — свойство ответа целиком, и nullable-флаг в каждой строке
|
||||||
|
// заставлял бы панель отвечать на этот вопрос заново для каждого пира.
|
||||||
|
const (
|
||||||
|
// PeerOnlineStateOk — Traffic Stats API ответил, `online` в строках
|
||||||
|
// означает то, что написано.
|
||||||
|
PeerOnlineStateOk = "ok"
|
||||||
|
// PeerOnlineStateUnavailable — спросить не удалось. `online = false` в
|
||||||
|
// строках при этом значении не означает НИЧЕГО.
|
||||||
|
PeerOnlineStateUnavailable = "unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeerPageVo — страница списка пиров.
|
||||||
|
//
|
||||||
|
// Что было. Список строился так:
|
||||||
|
//
|
||||||
|
// onlineUsers, _ := Hysteria2Online()
|
||||||
|
//
|
||||||
|
// Ошибка отбрасывалась, пустая карта разъезжалась по строкам как `online =
|
||||||
|
// false`, и любой сбой control plane превращался для оператора в утверждение
|
||||||
|
// «все пользователи офлайн» — вместо «состояние подключений сейчас
|
||||||
|
// неизвестно». Это два разных ответа, и первый из них в аварии ведёт искать
|
||||||
|
// проблему у пользователей.
|
||||||
type PeerPageVo struct {
|
type PeerPageVo struct {
|
||||||
Records []PeerVo `json:"records"`
|
Records []PeerVo `json:"records"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
// OnlineState — ok | unavailable.
|
||||||
|
OnlineState string `json:"onlineState"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerClientConfigVo — клиентская ссылка пира.
|
||||||
|
//
|
||||||
|
// Поля QrCode здесь больше нет. Оно было помечено deprecated и возило в
|
||||||
|
// браузер PNG, который панель не использует: QR рисуется во frontend из самой
|
||||||
|
// ссылки (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и
|
||||||
|
// вторым способом получить то же самое.
|
||||||
type PeerClientConfigVo struct {
|
type PeerClientConfigVo struct {
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
QrCode []byte `json:"qrCode,omitempty"` // deprecated: frontend renders SVG QR from Url
|
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-1
@@ -117,6 +117,35 @@ func GetHysteria2ApiPort() (int64, error) {
|
|||||||
return apiPort, nil
|
return apiPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Адрес Traffic Stats API — ОДИН контракт на обе половины продукта.
|
||||||
|
//
|
||||||
|
// Что было. Отсюда доставался только порт, а хост молча отбрасывался: слой
|
||||||
|
// proxy в любом случае собирает `http://127.0.0.1:<порт>`. Пока оркестратор
|
||||||
|
// разрешал в `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` произвольный IPv4, это давало
|
||||||
|
// конфигурацию, при которой Hysteria слушает один адрес, а админка стучится в
|
||||||
|
// другой — и весь контур доступа выключался fail-closed: `/online` недоступен,
|
||||||
|
// значит лимит устройств проверить нельзя, значит ни один пир не пускается.
|
||||||
|
//
|
||||||
|
// Оркестратор теперь фиксирует адрес на `127.0.0.1`, но эта функция читает
|
||||||
|
// ФАЙЛ, а файл может разойтись с оркестратором: правка руками, восстановление
|
||||||
|
// чужой копии, ручной эксперимент. Поэтому расхождение называется здесь, в
|
||||||
|
// первой же точке контакта, а не проявляется отказом соединения без объяснения.
|
||||||
|
//
|
||||||
|
// Молча подставлять loopback вместо прочитанного хоста нельзя ровно потому, что
|
||||||
|
// это и есть тот самый второй контракт: значение, которое админка приняла и
|
||||||
|
// проигнорировала.
|
||||||
|
//
|
||||||
|
// Что принимается:
|
||||||
|
//
|
||||||
|
// ":36712" -> хост не задан, Hysteria слушает loopback;
|
||||||
|
// "127.0.0.1:36712" -> канонический production-профиль;
|
||||||
|
// "127.0.0.5:36712" -> любой loopback достижим по loopback;
|
||||||
|
// "0.0.0.0:36712" -> wildcard включает loopback, обмен состоится.
|
||||||
|
//
|
||||||
|
// Wildcard принимается именно потому, что связь между компонентами при нём
|
||||||
|
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
|
||||||
|
// значило бы наказывать за неё отключением всех пиров. Запрет на wildcard —
|
||||||
|
// решение оркестратора, который конфиг создаёт (см. validateRuntimeConfig).
|
||||||
func parseTrafficStatsPort(listen string) (int64, error) {
|
func parseTrafficStatsPort(listen string) (int64, error) {
|
||||||
trimmed := strings.TrimSpace(listen)
|
trimmed := strings.TrimSpace(listen)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -128,10 +157,13 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
|||||||
hostPort = "127.0.0.1" + trimmed
|
hostPort = "127.0.0.1" + trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
_, portStr, err := net.SplitHostPort(hostPort)
|
host, portStr, err := net.SplitHostPort(hostPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if err := assertTrafficStatsHostReachable(host); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
port, err := strconv.ParseInt(portStr, 10, 64)
|
port, err := strconv.ParseInt(portStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -141,3 +173,29 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
|||||||
}
|
}
|
||||||
return port, nil
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому
|
||||||
|
// адресу — а не «нравится ли он профилю».
|
||||||
|
func assertTrafficStatsHostReachable(host string) error {
|
||||||
|
trimmed := strings.TrimSpace(host)
|
||||||
|
if trimmed == "" {
|
||||||
|
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. "+
|
||||||
|
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
|
||||||
|
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
|
||||||
|
trimmed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Адрес Traffic Stats API — межкомпонентный контракт, и админка обязана
|
||||||
|
// заметить его нарушение, а не работать «как будто там loopback».
|
||||||
|
//
|
||||||
|
// Ломается это одной строкой в `/etc/hysteria/config.yaml`: Hysteria начинает
|
||||||
|
// слушать другой адрес, админка продолжает стучаться в 127.0.0.1, и весь контур
|
||||||
|
// доступа выключается fail-closed — ни один пир не проходит авторизацию, потому
|
||||||
|
// что лимит устройств проверить нечем. Отказ обязан называть причину.
|
||||||
|
|
||||||
|
func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
listen string
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseTrafficStatsPort(tc.listen)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("адрес %q отклонён: %v", tc.listen, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("порт %d, ожидался %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
listen string
|
||||||
|
}{
|
||||||
|
{"адрес LAN", "192.168.1.10:36712"},
|
||||||
|
{"публичный адрес", "203.0.113.10:36712"},
|
||||||
|
{"имя хоста", "hysteria.internal:36712"},
|
||||||
|
{"пустая строка", ""},
|
||||||
|
{"порт вне диапазона", "127.0.0.1:70000"},
|
||||||
|
{"без порта", "127.0.0.1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := parseTrafficStatsPort(tc.listen); err == nil {
|
||||||
|
t.Fatalf("недостижимый адрес %q принят", tc.listen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ обязан называть и адрес, и способ починки: оператор, получивший
|
||||||
|
// «connection refused» без объяснения, ищет проблему в Hysteria.
|
||||||
|
func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
content := "listen: \":443\"\ntrafficStats:\n listen: \"192.168.1.10:36712\"\n secret: \"s\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("не удалось записать тестовый конфиг: %v", err)
|
||||||
|
}
|
||||||
|
previous := constant.Hysteria2ConfigPath
|
||||||
|
constant.Hysteria2ConfigPath = path
|
||||||
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||||
|
|
||||||
|
_, err := GetHysteria2ApiPort()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("админка приняла адрес, по которому не может обратиться")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "192.168.1.10") {
|
||||||
|
t.Errorf("сообщение не называет фактический адрес: %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reconfigure") {
|
||||||
|
t.Errorf("сообщение не называет способ починки: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -590,7 +590,7 @@ func TestSaveAccountTrafficCountsLostDeltas(t *testing.T) {
|
|||||||
func TestCronRunsWhenSystemdSaysStopped(t *testing.T) {
|
func TestCronRunsWhenSystemdSaysStopped(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||||
withHysteriaRunning(t, false)
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ func DashboardSummary() (vo.DashboardSummaryVo, error) {
|
|||||||
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
|
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
|
||||||
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
|
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
|
||||||
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
|
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
|
||||||
|
// До первого снимка сборщика о службе не известно ничего. Нулевое значение
|
||||||
|
// `Running = false` прочиталось бы как «остановлена» — то самое смешение
|
||||||
|
// «неизвестно» и «выключено», от которого избавляет HysteriaServiceState.
|
||||||
|
summary.Hysteria.ServiceState = string(HysteriaServiceUnknown)
|
||||||
|
|
||||||
snapshot := DashboardSnapshot()
|
snapshot := DashboardSnapshot()
|
||||||
if snapshot.CollectedAt > 0 {
|
if snapshot.CollectedAt > 0 {
|
||||||
@@ -119,9 +123,21 @@ func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
|
|||||||
return summary.SecurityRisks, nil
|
return summary.SecurityRisks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardSecurityRisks называет ровно то, что известно.
|
||||||
|
//
|
||||||
|
// «Служба остановлена» и «состояние службы неизвестно» — разные сообщения с
|
||||||
|
// разными действиями оператора. Пока состояние было булевым, второе выдавалось
|
||||||
|
// за первое: сломанный systemctl при живой Hysteria показывал критическую
|
||||||
|
// плашку «служба остановлена», и оператор шёл перезапускать работающий
|
||||||
|
// туннель.
|
||||||
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
|
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
|
||||||
risks := make([]vo.SecurityRiskVo, 0)
|
risks := make([]vo.SecurityRiskVo, 0)
|
||||||
if !summary.Hysteria.Running {
|
switch summary.Hysteria.ServiceState {
|
||||||
|
case string(HysteriaServiceActive):
|
||||||
|
// Обслуживание идёт — сообщать нечего.
|
||||||
|
case string(HysteriaServiceUnknown):
|
||||||
|
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStateUnknown", Severity: "warning", Dismissible: false})
|
||||||
|
default:
|
||||||
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
|
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
|
||||||
}
|
}
|
||||||
if !summary.Hysteria.ApiReachable {
|
if !summary.Hysteria.ApiReachable {
|
||||||
|
|||||||
+86
-24
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
)
|
)
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
||||||
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
||||||
// у которого спрашивает состояние Hysteria2IsRunning.
|
// у которого спрашивает состояние HysteriaServiceState.
|
||||||
//
|
//
|
||||||
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
||||||
// в которой панель сама запускала и останавливала Hysteria: clean install
|
// в которой панель сама запускала и останавливала Hysteria: clean install
|
||||||
@@ -26,38 +27,99 @@ func InitHysteria2() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hysteria2IsRunning — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
// Состояние службы имеет ТРИ значения, а не два.
|
||||||
//
|
//
|
||||||
// Точность этого ответа ограничена сверху устройством util.Exec: он схлопывает
|
// Что было. Ответ сводился к `bool`:
|
||||||
// «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в
|
|
||||||
// одну ошибку. Различить их здесь нельзя, поэтому false означает «служба
|
|
||||||
// неактивна ИЛИ спросить не получилось».
|
|
||||||
//
|
//
|
||||||
// Отсюда правило, которое стоило продукту трёх дыр: на этом значении нельзя
|
// func Hysteria2IsRunning() bool {
|
||||||
// строить решения о доступе и о применении операции. Ему место в отображении
|
// _, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||||
// (дашборд, признак online в списке), где ошибочное «выключено» стоит одной
|
// return err == nil
|
||||||
// неверной плашки. Решения о доступе принимаются по фактическому ответу
|
// }
|
||||||
// Traffic Stats API — см. hysteria2Online и disconnectAuthIDs.
|
|
||||||
//
|
//
|
||||||
// Третья дыра была самой дорогой и жила в cron: гейт `if !Hysteria2IsRunning()
|
// `false` при этом означал сразу две несовместимые вещи — «служба неактивна» и
|
||||||
// { return }` стоял перед всем циклом учёта, поэтому сломанный systemctl при
|
// «спросить не получилось», — потому что util.Exec выбрасывает вывод команды,
|
||||||
// живой Hysteria молча отключал и сбор трафика, и принудительное отключение —
|
// как только код возврата не нулевой, а `is-active` отвечает именно кодом 3
|
||||||
// без единой строки в журнале.
|
// вместе со словом состояния в stdout. Прочитать это слово было нечем.
|
||||||
func Hysteria2IsRunning() bool {
|
//
|
||||||
return hysteria2IsRunning()
|
// Цена такой неопределённости оказалась не в одной неверной плашке. Пока
|
||||||
|
// «неизвестно» выглядело как «остановлена», на этом значении стояли решения:
|
||||||
|
// цикл учёта и проверка лимита устройств пропускались при сломанном systemctl
|
||||||
|
// и живой Hysteria. Оба гейта убраны (см. cron.go и Hysteria2Auth), и это
|
||||||
|
// правильно, но сама неопределённость никуда не делась — она осталась в
|
||||||
|
// диагностике, где как раз и вредна:
|
||||||
|
//
|
||||||
|
// Hysteria stopped
|
||||||
|
// Traffic Stats API reachable
|
||||||
|
// 0 online users
|
||||||
|
//
|
||||||
|
// Три утверждения об одной системе, из которых первое и второе не могут быть
|
||||||
|
// верны одновременно. Оператор, увидев такое в аварии, ищет не там.
|
||||||
|
//
|
||||||
|
// Теперь состояние читается словом, а не кодом возврата, и неизвестность
|
||||||
|
// называется неизвестностью.
|
||||||
|
type HysteriaServiceState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// HysteriaServiceActive — systemd сообщил "active".
|
||||||
|
HysteriaServiceActive HysteriaServiceState = "active"
|
||||||
|
// HysteriaServiceInactive — systemd назвал любое другое известное
|
||||||
|
// состояние: inactive, failed, activating, deactivating.
|
||||||
|
//
|
||||||
|
// Различать их дальше продукту незачем: панель отвечает на вопрос «идёт ли
|
||||||
|
// сейчас обслуживание», а не пересказывает конечный автомат systemd.
|
||||||
|
HysteriaServiceInactive HysteriaServiceState = "inactive"
|
||||||
|
// HysteriaServiceUnknown — спросить не удалось либо ответ не разобран.
|
||||||
|
//
|
||||||
|
// Это НЕ «служба остановлена». Единственное честное следствие — что о
|
||||||
|
// состоянии службы сейчас ничего не известно.
|
||||||
|
HysteriaServiceUnknown HysteriaServiceState = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hysteria2ServiceState — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||||
|
//
|
||||||
|
// Значение годится для отображения и ни для чего больше. Решения о доступе
|
||||||
|
// принимаются по фактическому ответу Traffic Stats API — см. hysteria2Online и
|
||||||
|
// disconnectAuthIDs, — и это правило стоило продукту трёх дыр: гейт
|
||||||
|
// `if !Hysteria2IsRunning() { return }` в cron молча отключал и сбор трафика, и
|
||||||
|
// принудительное отключение при сломанном systemctl.
|
||||||
|
func Hysteria2ServiceState() HysteriaServiceState {
|
||||||
|
return hysteriaServiceState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// hysteria2IsRunning — та же проверка в виде значения.
|
// hysteriaServiceState — та же проверка в виде значения.
|
||||||
//
|
//
|
||||||
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
||||||
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
||||||
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
||||||
// прежняя реализация.
|
// реализация.
|
||||||
var hysteria2IsRunning = func() bool {
|
var hysteriaServiceState = func() HysteriaServiceState {
|
||||||
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
// `--quiet` здесь недопустим: он подавляет ровно тот вывод, ради которого
|
||||||
return err == nil
|
// команда вызывается.
|
||||||
|
output, _, err := util.ExecProbe("systemctl is-active hysteria-server")
|
||||||
|
if err != nil {
|
||||||
|
return HysteriaServiceUnknown
|
||||||
|
}
|
||||||
|
return parseSystemdActiveState(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReleaseHysteria2() error {
|
// parseSystemdActiveState разбирает ответ `systemctl is-active`.
|
||||||
return nil
|
//
|
||||||
|
// Слово состояния приезжает первой строкой. Неизвестное значение считается
|
||||||
|
// неизвестным состоянием, а не «неактивна»: список состояний systemd
|
||||||
|
// пополнялся и раньше, и молчаливое приведение нового слова к «остановлена»
|
||||||
|
// вернуло бы ровно ту ложь, от которой избавляет этот тип.
|
||||||
|
func parseSystemdActiveState(output string) HysteriaServiceState {
|
||||||
|
state := strings.TrimSpace(output)
|
||||||
|
if idx := strings.IndexAny(state, "\r\n"); idx >= 0 {
|
||||||
|
state = strings.TrimSpace(state[:idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case "active":
|
||||||
|
return HysteriaServiceActive
|
||||||
|
case "inactive", "failed", "activating", "deactivating", "reloading", "maintenance":
|
||||||
|
return HysteriaServiceInactive
|
||||||
|
default:
|
||||||
|
return HysteriaServiceUnknown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,20 +165,36 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
|||||||
|
|
||||||
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
||||||
//
|
//
|
||||||
// Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего
|
// Отличается от hysteria2Online ровно ничем, и это результат исправления, а не
|
||||||
// показать, если Traffic Stats API молчит, и пустая картина — честный ответ на
|
// упущение. Раньше здесь стоял ярлык
|
||||||
// вопрос «кто сейчас на связи», когда служба остановлена.
|
|
||||||
//
|
//
|
||||||
// Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() ->
|
// if !hysteria2IsRunning() {
|
||||||
// пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение
|
// return map[string]int64{}, nil
|
||||||
// устройств обходилось молча: systemctl отвечает через util.Exec, который не
|
// }
|
||||||
// отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн»
|
//
|
||||||
// для проверки лимита означает «пускать всех». Отказа при этом не возникало
|
// то есть «пусто, ошибки нет» по мнению systemd. У него было два следствия, и
|
||||||
// вовсе — не было даже записи в журнале.
|
// оба вредные.
|
||||||
|
//
|
||||||
|
// Первое — на пути доступа: ответ systemctl не отличает «служба неактивна» от
|
||||||
|
// «спросить не удалось», а пустая картина при проверке лимита устройств
|
||||||
|
// означает «пускать всех». Эта половина закрыта раньше — авторизация ходит
|
||||||
|
// строгим путём.
|
||||||
|
//
|
||||||
|
// Второе осталось и живёт в диагностике. Пустая карта БЕЗ ошибки неотличима от
|
||||||
|
// «никто не подключён», поэтому сборщик метрик выставлял `ApiReachable = true`
|
||||||
|
// и `OnlineDevices = 0`, ни разу не обратившись к Traffic Stats API, а список
|
||||||
|
// пиров показывал всех офлайн. Дашборд утверждал одновременно «служба
|
||||||
|
// остановлена» и «API доступен, онлайн 0» — два несовместимых факта об одной
|
||||||
|
// системе, полученные из одного и того же ответа systemctl.
|
||||||
|
//
|
||||||
|
// Поэтому ярлыка нет: «кто сейчас на связи» спрашивается у того, кто это
|
||||||
|
// знает. Недоступность остаётся ОШИБКОЙ, а решать, как её показать оператору,
|
||||||
|
// обязан вызывающий — см. CollectMetricsSnapshot и PagePeer, где она
|
||||||
|
// превращается в явное «состояние неизвестно», а не в «все офлайн».
|
||||||
|
//
|
||||||
|
// Функция сохранена отдельно от hysteria2Online как имя для внешнего слоя:
|
||||||
|
// внутри пакета строгий путь остаётся строчным.
|
||||||
func Hysteria2Online() (map[string]int64, error) {
|
func Hysteria2Online() (map[string]int64, error) {
|
||||||
if !hysteria2IsRunning() {
|
|
||||||
return map[string]int64{}, nil
|
|
||||||
}
|
|
||||||
return hysteria2Online()
|
return hysteria2Online()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +346,7 @@ func Hysteria2Url(accountId int64) (string, error) {
|
|||||||
// несколькими пирами это ровно то различие, которое пользователю и нужно
|
// несколькими пирами это ровно то различие, которое пользователю и нужно
|
||||||
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
|
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
|
||||||
// публичный хост нужен для пира без имени — база это допускает (name имеет
|
// публичный хост нужен для пира без имени — база это допускает (name имеет
|
||||||
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
// DEFAULT ”), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||||
func shareRemark(peerName *string, hostname string) string {
|
func shareRemark(peerName *string, hostname string) string {
|
||||||
if peerName != nil {
|
if peerName != nil {
|
||||||
if name := strings.TrimSpace(*peerName); name != "" {
|
if name := strings.TrimSpace(*peerName); name != "" {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
redactNode(&document, nil)
|
redactNode(&document, nil, newAliasGuard())
|
||||||
|
|
||||||
out, err := yaml.Marshal(&document)
|
out, err := yaml.Marshal(&document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +120,53 @@ func isSecretMapPath(path []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactNode(node *yaml.Node, path []string) {
|
// Секрет, спрятанный за YAML-якорем, тоже покидал сервер.
|
||||||
|
//
|
||||||
|
// Что было. И redactNode, и redactSubtree разбирали четыре вида узлов:
|
||||||
|
// документ, последовательность, отображение и скаляр. Пятый вид —
|
||||||
|
// yaml.AliasNode — не обрабатывался ни одной из них, а yaml.v3 якоря и ссылки
|
||||||
|
// поддерживает:
|
||||||
|
//
|
||||||
|
// shared: &credential VERY_SECRET_VALUE
|
||||||
|
//
|
||||||
|
// obfs:
|
||||||
|
// type: salamander
|
||||||
|
// salamander:
|
||||||
|
// password: *credential
|
||||||
|
//
|
||||||
|
// Утечек здесь ДВЕ, и обе тихие. Значение под ключом `password` — это
|
||||||
|
// AliasNode, и redactSubtree на нём был no-op. А сам якорь объявлен под
|
||||||
|
// ключом `shared`, который секретоподобным не выглядит, поэтому и он
|
||||||
|
// оставался нетронутым. В выгрузке секрет оказывался дважды.
|
||||||
|
//
|
||||||
|
// Канонический конфиг HY2XS якорей не содержит, но экспорт существует ещё и
|
||||||
|
// для диагностики чужих и правленых руками файлов, а обещание «секреты не
|
||||||
|
// покидают сервер» либо абсолютно, либо его нет.
|
||||||
|
//
|
||||||
|
// Лечится переходом ПО ССЫЛКЕ: alias ведёт к тому же узлу, что и якорь,
|
||||||
|
// поэтому редакция цели закрывает оба вхождения сразу. Защита от циклов
|
||||||
|
// обязательна — YAML допускает ссылку на предка, и обход без неё не завершится.
|
||||||
|
|
||||||
|
// aliasGuard помнит уже посещённые узлы обхода.
|
||||||
|
type aliasGuard map[*yaml.Node]struct{}
|
||||||
|
|
||||||
|
func newAliasGuard() aliasGuard {
|
||||||
|
return aliasGuard{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enter возвращает false, если узел уже посещался в этом обходе.
|
||||||
|
func (g aliasGuard) enter(node *yaml.Node) bool {
|
||||||
|
if node == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, seen := g[node]; seen {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
g[node] = struct{}{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactNode(node *yaml.Node, path []string, guard aliasGuard) {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,18 +174,25 @@ func redactNode(node *yaml.Node, path []string) {
|
|||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.DocumentNode:
|
case yaml.DocumentNode:
|
||||||
for _, child := range node.Content {
|
for _, child := range node.Content {
|
||||||
redactNode(child, path)
|
redactNode(child, path, guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
case yaml.AliasNode:
|
||||||
|
// Обход продолжается по цели ссылки: путь остаётся прежним, потому что
|
||||||
|
// alias стоит ровно там, где стояло бы само значение.
|
||||||
|
if guard.enter(node) {
|
||||||
|
redactNode(node.Alias, path, guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
for _, child := range node.Content {
|
for _, child := range node.Content {
|
||||||
// Индекс не попадает в путь: правила формулируются по именам полей.
|
// Индекс не попадает в путь: правила формулируются по именам полей.
|
||||||
redactNode(child, path)
|
redactNode(child, path, guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
if isSecretMapPath(path) {
|
if isSecretMapPath(path) {
|
||||||
redactAllScalarValues(node)
|
redactAllScalarValues(node, guard)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
@@ -148,10 +201,10 @@ func redactNode(node *yaml.Node, path []string) {
|
|||||||
childPath := append(append([]string{}, path...), key)
|
childPath := append(append([]string{}, path...), key)
|
||||||
|
|
||||||
if isSecretKey(key) {
|
if isSecretKey(key) {
|
||||||
redactSubtree(value)
|
redactSubtree(value, guard)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
redactNode(value, childPath)
|
redactNode(value, childPath, guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
@@ -182,30 +235,40 @@ func sanitizeURLScalar(node *yaml.Node) {
|
|||||||
node.Style = 0
|
node.Style = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactSubtree(node *yaml.Node) {
|
func redactSubtree(node *yaml.Node, guard aliasGuard) {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
setRedacted(node)
|
setRedacted(node)
|
||||||
|
case yaml.AliasNode:
|
||||||
|
// Редактируется ЦЕЛЬ ссылки: она же является значением якоря, поэтому
|
||||||
|
// одно действие закрывает и `*alias`, и место объявления `&anchor`.
|
||||||
|
if guard.enter(node) {
|
||||||
|
redactSubtree(node.Alias, guard)
|
||||||
|
}
|
||||||
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
|
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
|
||||||
redactAllScalarValues(node)
|
redactAllScalarValues(node, guard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactAllScalarValues(node *yaml.Node) {
|
func redactAllScalarValues(node *yaml.Node, guard aliasGuard) {
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
redactSubtree(node.Content[i+1])
|
redactSubtree(node.Content[i+1], guard)
|
||||||
}
|
}
|
||||||
case yaml.SequenceNode, yaml.DocumentNode:
|
case yaml.SequenceNode, yaml.DocumentNode:
|
||||||
for _, child := range node.Content {
|
for _, child := range node.Content {
|
||||||
redactSubtree(child)
|
redactSubtree(child, guard)
|
||||||
}
|
}
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
setRedacted(node)
|
setRedacted(node)
|
||||||
|
case yaml.AliasNode:
|
||||||
|
if guard.enter(node) {
|
||||||
|
redactSubtree(node.Alias, guard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -232,6 +233,132 @@ func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Якоря и ссылки YAML ------------------------------------------------------
|
||||||
|
|
||||||
|
// Секрет, спрятанный за якорем, покидал сервер ДВАЖДЫ.
|
||||||
|
//
|
||||||
|
// redactNode и redactSubtree разбирали документ, последовательность,
|
||||||
|
// отображение и скаляр, но не yaml.AliasNode. Значение под ключом `password`
|
||||||
|
// было ссылкой — и redactSubtree на ней не делал ничего; сам якорь объявлен под
|
||||||
|
// ключом `shared`, секретоподобным не выглядящим, — и его не трогал никто.
|
||||||
|
//
|
||||||
|
// Канонический конфиг HY2XS якорей не содержит, но выгрузка существует ещё и
|
||||||
|
// для диагностики правленых руками файлов, а обещание «секреты не покидают
|
||||||
|
// сервер» либо абсолютно, либо его нет.
|
||||||
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredSecrets(t *testing.T) {
|
||||||
|
raw := `shared: &credential VERY_SECRET_VALUE
|
||||||
|
|
||||||
|
listen: 0.0.0.0:443
|
||||||
|
|
||||||
|
obfs:
|
||||||
|
type: salamander
|
||||||
|
salamander:
|
||||||
|
password: *credential
|
||||||
|
`
|
||||||
|
|
||||||
|
sanitized := sanitizeForTest(t, raw)
|
||||||
|
|
||||||
|
if strings.Contains(sanitized, "VERY_SECRET_VALUE") {
|
||||||
|
t.Fatalf("секрет за якорем уехал в выгрузку:\n%s", sanitized)
|
||||||
|
}
|
||||||
|
// Документ обязан остаться валидным YAML: ссылка без якоря его сломала бы.
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||||
|
t.Fatalf("выгрузка перестала быть валидным YAML: %v\n%s", err, sanitized)
|
||||||
|
}
|
||||||
|
obfs, ok := parsed["obfs"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("секция obfs потеряна:\n%s", sanitized)
|
||||||
|
}
|
||||||
|
salamander, ok := obfs["salamander"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("подсекция salamander потеряна: %+v", obfs)
|
||||||
|
}
|
||||||
|
if salamander["password"] != RedactedPlaceholder {
|
||||||
|
t.Fatalf("значение по ссылке не вырезано: %+v", salamander)
|
||||||
|
}
|
||||||
|
if parsed["shared"] != RedactedPlaceholder {
|
||||||
|
t.Fatalf("объявление якоря осталось нетронутым: %+v", parsed["shared"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL с учётными данными, спрятанный за якорем, — тот же случай.
|
||||||
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredURLCredentials(t *testing.T) {
|
||||||
|
raw := `shared: &endpoint https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||||
|
|
||||||
|
someFutureUpstreamFeature:
|
||||||
|
endpoint: *endpoint
|
||||||
|
`
|
||||||
|
|
||||||
|
sanitized := sanitizeForTest(t, raw)
|
||||||
|
|
||||||
|
for _, secret := range []string{"svc-p4ss", "endpoint-token"} {
|
||||||
|
if strings.Contains(sanitized, secret) {
|
||||||
|
t.Fatalf("секрет %q уехал в выгрузку через якорь:\n%s", secret, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(sanitized, "relay.example.com") {
|
||||||
|
t.Fatalf("адрес вырезан целиком вместо учётных данных:\n%s", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка на составной узел: редактируется всё поддерево цели.
|
||||||
|
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredMapping(t *testing.T) {
|
||||||
|
raw := `shared: &creds
|
||||||
|
username: proxyuser
|
||||||
|
password: proxy-password
|
||||||
|
|
||||||
|
outbounds:
|
||||||
|
- name: upstream
|
||||||
|
type: socks5
|
||||||
|
socks5: *creds
|
||||||
|
`
|
||||||
|
|
||||||
|
sanitized := sanitizeForTest(t, raw)
|
||||||
|
|
||||||
|
if strings.Contains(sanitized, "proxy-password") {
|
||||||
|
t.Fatalf("пароль в составном якоре уехал в выгрузку:\n%s", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка на предка образует цикл. Без защиты обход не завершился бы вовсе —
|
||||||
|
// это отказ обслуживания на выгрузке конфига, а не утечка.
|
||||||
|
func TestSanitizeHysteria2ConfigYaml_SurvivesRecursiveAlias(t *testing.T) {
|
||||||
|
raw := `root: &root
|
||||||
|
password: cycle-secret
|
||||||
|
self: *root
|
||||||
|
`
|
||||||
|
|
||||||
|
type outcome struct {
|
||||||
|
sanitized string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan outcome, 1)
|
||||||
|
go func() {
|
||||||
|
out, err := SanitizeHysteria2ConfigYaml(raw)
|
||||||
|
done <- outcome{sanitized: string(out), err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
// yaml.v3 строит на такой записи ДЕЙСТВИТЕЛЬНО циклический граф узлов
|
||||||
|
// (alias указывает на объемлющее отображение) и умеет его же обратно
|
||||||
|
// сериализовать, поэтому отказ здесь был бы регрессией, а не
|
||||||
|
// допустимым исходом.
|
||||||
|
if got.err != nil {
|
||||||
|
t.Fatalf("выгрузка отказала на рекурсивной ссылке: %v", got.err)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.sanitized, "cycle-secret") {
|
||||||
|
t.Fatalf("секрет в рекурсивной структуре уехал в выгрузку:\n%s", got.sanitized)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.sanitized, RedactedPlaceholder) {
|
||||||
|
t.Fatalf("секрет исчез вместе со структурой вместо редакции:\n%s", got.sanitized)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("санитайзер зациклился на ссылке, указывающей на предка")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeURLValue(t *testing.T) {
|
func TestSanitizeURLValue(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/bo"
|
||||||
|
"hy2xs-admin/model/vo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Проекция серверного конфига на production-профиль HY2XS.
|
||||||
|
//
|
||||||
|
// Зачем она нужна вместо «показать модель целиком». Продуктом является один
|
||||||
|
// профиль: оркестратор генерирует конфиг из шаблона и сам же проверяет, что
|
||||||
|
// установленный файл этому профилю соответствует
|
||||||
|
// (orchestrator/src/steps/configAssertions.ts). Страница конфигурации при этом
|
||||||
|
// обслуживала одновременно и его, и абстрактный «универсальный сервер
|
||||||
|
// Hysteria» — с вкладками resolver, sniff, acl, outbounds, masquerade, — то
|
||||||
|
// есть показывала как настройки то, что в этой установке не используется и не
|
||||||
|
// поддерживается.
|
||||||
|
//
|
||||||
|
// Достраивать её до полноценного редактора всех возможностей upstream
|
||||||
|
// неправильно: конфигом владеет оркестратор, панель его не пишет, а вопрос
|
||||||
|
// «почему поведение не такое, как ожидалось» решается не списком всех полей, а
|
||||||
|
// ответом «что в файле отличается от профиля». Поэтому здесь ровно две вещи:
|
||||||
|
// значения профиля как они записаны и перечень секций вне профиля.
|
||||||
|
//
|
||||||
|
// Полный документ никуда не делся — он выгружается санитизированным экспортом.
|
||||||
|
|
||||||
|
// hysteria2ProfileSections — секции верхнего уровня, которые описывает
|
||||||
|
// production-профиль HY2XS.
|
||||||
|
//
|
||||||
|
// Список обязан совпадать с whitelist'ом оркестратора
|
||||||
|
// (assertNoUnexpectedSections) с точностью до того, что там режимы TLS
|
||||||
|
// взаимоисключающие, а здесь показываются оба: файл может содержать любой из
|
||||||
|
// них, и оба принадлежат профилю. Совпадение проверяется гейтом приёмки —
|
||||||
|
// иначе два списка разъедутся молча, и панель начнёт называть расхождением то,
|
||||||
|
// что оркестратор считает нормой.
|
||||||
|
var hysteria2ProfileSections = []string{
|
||||||
|
"acme",
|
||||||
|
"auth",
|
||||||
|
"bandwidth",
|
||||||
|
"congestion",
|
||||||
|
"ignoreClientBandwidth",
|
||||||
|
"listen",
|
||||||
|
"obfs",
|
||||||
|
"quic",
|
||||||
|
"tls",
|
||||||
|
"trafficStats",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildHysteria2Profile читает файл ОДИН раз и отвечает на оба вопроса сразу:
|
||||||
|
// что записано в секциях профиля и какие секции в профиль не входят.
|
||||||
|
func BuildHysteria2Profile() (vo.Hysteria2ProfileVo, error) {
|
||||||
|
raw, err := GetRawHysteria2Config()
|
||||||
|
if err != nil {
|
||||||
|
return vo.Hysteria2ProfileVo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config bo.Hysteria2ServerConfig
|
||||||
|
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
|
||||||
|
return vo.Hysteria2ProfileVo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := hysteria2ProfileFromConfig(config)
|
||||||
|
profile.Drift = hysteria2ConfigDrift(raw)
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteria2ConfigDrift перечисляет секции файла вне production-профиля.
|
||||||
|
//
|
||||||
|
// Разбор идёт в свободную карту, а не в типизированную модель: секция, о
|
||||||
|
// которой HY2XS не знает вовсе, обязана попасть в расхождения, а модель её
|
||||||
|
// молча теряет. Именно такие секции и интересны — они означают, что файл правил
|
||||||
|
// не оркестратор.
|
||||||
|
func hysteria2ConfigDrift(raw string) []string {
|
||||||
|
var document map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
|
||||||
|
// Файл не разобрался как карта верхнего уровня. Значения профиля уже
|
||||||
|
// прочитаны типизированной моделью; сказать что-либо о расхождениях
|
||||||
|
// нельзя, и выдумывать их нечестно.
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
known := make(map[string]struct{}, len(hysteria2ProfileSections))
|
||||||
|
for _, section := range hysteria2ProfileSections {
|
||||||
|
known[section] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
drift := make([]string, 0)
|
||||||
|
for key := range document {
|
||||||
|
if _, ok := known[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drift = append(drift, key)
|
||||||
|
}
|
||||||
|
// Порядок ключей карты в Go случаен: без сортировки список расхождений
|
||||||
|
// менялся бы при каждом обновлении страницы.
|
||||||
|
sort.Strings(drift)
|
||||||
|
return drift
|
||||||
|
}
|
||||||
|
|
||||||
|
func hysteria2ProfileFromConfig(config bo.Hysteria2ServerConfig) vo.Hysteria2ProfileVo {
|
||||||
|
profile := vo.Hysteria2ProfileVo{
|
||||||
|
Listen: config.Listen,
|
||||||
|
IgnoreClientBandwidth: config.IgnoreClientBandwidth,
|
||||||
|
Drift: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth := config.Auth; auth != nil {
|
||||||
|
profile.Auth = &vo.Hysteria2ProfileAuthVo{Type: auth.Type}
|
||||||
|
if auth.HTTP != nil {
|
||||||
|
// URL несёт machine token в query. Санитайзер тот же, что и у
|
||||||
|
// выгрузки: адрес остаётся читаемым, секрет — нет.
|
||||||
|
if auth.HTTP.URL != nil {
|
||||||
|
sanitized := sanitizeURLValue(*auth.HTTP.URL)
|
||||||
|
profile.Auth.Url = &sanitized
|
||||||
|
}
|
||||||
|
profile.Auth.Insecure = auth.HTTP.Insecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tls := config.TLS; tls != nil {
|
||||||
|
profile.Tls = &vo.Hysteria2ProfileTlsVo{
|
||||||
|
Cert: tls.Cert,
|
||||||
|
Key: tls.Key,
|
||||||
|
SniGuard: tls.SNIGuard,
|
||||||
|
ClientCA: tls.ClientCA,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acme := config.ACME; acme != nil {
|
||||||
|
profile.Acme = &vo.Hysteria2ProfileAcmeVo{
|
||||||
|
Domains: acme.Domains,
|
||||||
|
Email: acme.Email,
|
||||||
|
Ca: acme.CA,
|
||||||
|
Dir: acme.Dir,
|
||||||
|
ListenHost: acme.ListenHost,
|
||||||
|
Type: acme.Type,
|
||||||
|
DnsConfigKeys: []string{},
|
||||||
|
}
|
||||||
|
if acme.DNS != nil {
|
||||||
|
profile.Acme.DnsProvider = acme.DNS.Name
|
||||||
|
for key := range acme.DNS.Config {
|
||||||
|
profile.Acme.DnsConfigKeys = append(profile.Acme.DnsConfigKeys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(profile.Acme.DnsConfigKeys)
|
||||||
|
}
|
||||||
|
if profile.Acme.Domains == nil {
|
||||||
|
profile.Acme.Domains = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if obfs := config.Obfs; obfs != nil {
|
||||||
|
profile.Obfs = &vo.Hysteria2ProfileObfsVo{Type: obfs.Type}
|
||||||
|
// Показывается ветка, соответствующая объявленному типу. Наличие
|
||||||
|
// «второй» ветки рядом — это уже расхождение конфига, и его видно по
|
||||||
|
// самому типу: оркестратор пишет ровно одну.
|
||||||
|
share := config.ObfsShare()
|
||||||
|
profile.Obfs.PasswordSet = strings.TrimSpace(share.Password) != ""
|
||||||
|
if obfs.Gecko != nil && obfs.Type != nil && *obfs.Type == "gecko" {
|
||||||
|
profile.Obfs.MinPacketSize = obfs.Gecko.MinPacketSize
|
||||||
|
profile.Obfs.MaxPacketSize = obfs.Gecko.MaxPacketSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bandwidth := config.Bandwidth; bandwidth != nil {
|
||||||
|
profile.Bandwidth = &vo.Hysteria2ProfileBandwidthVo{
|
||||||
|
Up: bandwidth.Up,
|
||||||
|
Down: bandwidth.Down,
|
||||||
|
DisableLossCompensation: bandwidth.DisableLossCompensation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if congestion := config.Congestion; congestion != nil {
|
||||||
|
profile.Congestion = &vo.Hysteria2ProfileCongestionVo{
|
||||||
|
Type: congestion.Type,
|
||||||
|
BbrProfile: congestion.BBRProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if quic := config.QUIC; quic != nil {
|
||||||
|
profile.Quic = &vo.Hysteria2ProfileQuicVo{
|
||||||
|
InitStreamReceiveWindow: quic.InitStreamReceiveWindow,
|
||||||
|
MaxStreamReceiveWindow: quic.MaxStreamReceiveWindow,
|
||||||
|
InitConnReceiveWindow: quic.InitConnectionReceiveWindow,
|
||||||
|
MaxConnReceiveWindow: quic.MaxConnectionReceiveWindow,
|
||||||
|
MaxIdleTimeout: quic.MaxIdleTimeout,
|
||||||
|
MaxIncomingStreams: quic.MaxIncomingStreams,
|
||||||
|
DisablePathMTUDiscovery: quic.DisablePathMTUDiscovery,
|
||||||
|
DisableStatelessReset: quic.DisableStatelessReset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats := config.TrafficStats; stats != nil {
|
||||||
|
profile.TrafficStats = &vo.Hysteria2ProfileTrafficStatsVo{
|
||||||
|
Listen: stats.Listen,
|
||||||
|
SecretSet: stats.Secret != nil && strings.TrimSpace(*stats.Secret) != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/model/vo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Страница конфигурации показывает файл, а не представление о файле.
|
||||||
|
//
|
||||||
|
// Прежний ответ отдавал внутреннюю модель целиком, а панель накладывала его на
|
||||||
|
// полный объект дефолтов. Проверки ниже фиксируют обе половины исправления:
|
||||||
|
// отсутствие секции остаётся отсутствием, а секции вне production-профиля
|
||||||
|
// называются расхождением, а не теряются.
|
||||||
|
|
||||||
|
func writeHysteriaConfig(t *testing.T, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("не удалось записать тестовый конфиг: %v", err)
|
||||||
|
}
|
||||||
|
previous := constant.Hysteria2ConfigPath
|
||||||
|
constant.Hysteria2ConfigPath = path
|
||||||
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalProfileConfig — то, что генерирует оркестратор.
|
||||||
|
const canonicalProfileConfig = `listen: 0.0.0.0:443
|
||||||
|
|
||||||
|
acme:
|
||||||
|
domains:
|
||||||
|
- vpn.example.com
|
||||||
|
email: admin@example.com
|
||||||
|
ca: letsencrypt
|
||||||
|
listenHost: 0.0.0.0
|
||||||
|
dir: /var/lib/hysteria/acme
|
||||||
|
type: http
|
||||||
|
|
||||||
|
auth:
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
obfs:
|
||||||
|
type: gecko
|
||||||
|
gecko:
|
||||||
|
password: gecko-obfs-secret
|
||||||
|
minPacketSize: 512
|
||||||
|
maxPacketSize: 1200
|
||||||
|
|
||||||
|
bandwidth:
|
||||||
|
up: 50 mbps
|
||||||
|
down: 50 mbps
|
||||||
|
disableLossCompensation: false
|
||||||
|
|
||||||
|
ignoreClientBandwidth: false
|
||||||
|
|
||||||
|
congestion:
|
||||||
|
type: bbr
|
||||||
|
bbrProfile: standard
|
||||||
|
|
||||||
|
trafficStats:
|
||||||
|
listen: 127.0.0.1:36712
|
||||||
|
secret: traffic-stats-secret
|
||||||
|
|
||||||
|
quic:
|
||||||
|
initStreamReceiveWindow: 8388608
|
||||||
|
maxStreamReceiveWindow: 8388608
|
||||||
|
initConnReceiveWindow: 20971520
|
||||||
|
maxConnReceiveWindow: 20971520
|
||||||
|
maxIdleTimeout: 30s
|
||||||
|
maxIncomingStreams: 1024
|
||||||
|
disablePathMTUDiscovery: false
|
||||||
|
disableStatelessReset: false
|
||||||
|
`
|
||||||
|
|
||||||
|
// profileJSON сериализует ответ ровно так, как его получит браузер.
|
||||||
|
//
|
||||||
|
// Проверять секреты по полям недостаточно: утечка происходит в СЕРИАЛИЗАЦИИ, и
|
||||||
|
// новое поле без `json:"-"` не заметит ни одна проверка по именам.
|
||||||
|
func profileJSON(t *testing.T, profile vo.Hysteria2ProfileVo) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(profile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ответ не сериализуется: %v", err)
|
||||||
|
}
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHysteria2ProfileReadsCanonicalConfig(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, canonicalProfileConfig)
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.Listen == nil || *profile.Listen != "0.0.0.0:443" {
|
||||||
|
t.Fatalf("listen: %v", profile.Listen)
|
||||||
|
}
|
||||||
|
if profile.TrafficStats == nil || profile.TrafficStats.Listen == nil ||
|
||||||
|
*profile.TrafficStats.Listen != "127.0.0.1:36712" {
|
||||||
|
t.Fatalf("trafficStats.listen: %+v", profile.TrafficStats)
|
||||||
|
}
|
||||||
|
if profile.TrafficStats.SecretSet != true {
|
||||||
|
t.Fatal("наличие секрета Traffic Stats API не показано")
|
||||||
|
}
|
||||||
|
if profile.IgnoreClientBandwidth == nil || *profile.IgnoreClientBandwidth {
|
||||||
|
t.Fatalf("ignoreClientBandwidth: %v", profile.IgnoreClientBandwidth)
|
||||||
|
}
|
||||||
|
if profile.Congestion == nil || profile.Congestion.BbrProfile == nil ||
|
||||||
|
*profile.Congestion.BbrProfile != "standard" {
|
||||||
|
t.Fatalf("congestion: %+v", profile.Congestion)
|
||||||
|
}
|
||||||
|
if profile.Quic == nil || profile.Quic.MaxIdleTimeout == nil || *profile.Quic.MaxIdleTimeout != "30s" {
|
||||||
|
t.Fatalf("quic: %+v", profile.Quic)
|
||||||
|
}
|
||||||
|
if len(profile.Drift) != 0 {
|
||||||
|
t.Fatalf("канонический конфиг объявлен расходящимся: %v", profile.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствующая секция остаётся отсутствующей.
|
||||||
|
//
|
||||||
|
// Главная регрессия: без `trafficStats` панель показывала `:9999` — адрес,
|
||||||
|
// которого в файле нет и никогда не было. Экран, существующий ради
|
||||||
|
// диагностики, скрывал ровно то состояние, которое надо диагностировать:
|
||||||
|
// «Traffic API не работает» и «трафик не собирается» объясняются именно этим.
|
||||||
|
func TestBuildHysteria2ProfileKeepsMissingSectionsMissing(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, "listen: 0.0.0.0:443\n")
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.TrafficStats != nil {
|
||||||
|
t.Fatalf("отсутствующая секция trafficStats придумана: %+v", profile.TrafficStats)
|
||||||
|
}
|
||||||
|
if profile.Acme != nil || profile.Tls != nil {
|
||||||
|
t.Fatal("отсутствующая конфигурация TLS придумана")
|
||||||
|
}
|
||||||
|
if profile.Obfs != nil || profile.Bandwidth != nil || profile.Congestion != nil || profile.Quic != nil {
|
||||||
|
t.Fatal("отсутствующие секции придуманы")
|
||||||
|
}
|
||||||
|
if profile.IgnoreClientBandwidth != nil {
|
||||||
|
t.Fatalf("отсутствующий ignoreClientBandwidth придуман: %v", *profile.IgnoreClientBandwidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Явное `false` — это значение, а не отсутствие.
|
||||||
|
//
|
||||||
|
// Прежняя панель приводила секции к `!!data.speedTest` и `!!data.disableUDP`,
|
||||||
|
// поэтому явно выключенная опция выглядела ненастроенной и прятала свою
|
||||||
|
// вкладку целиком.
|
||||||
|
func TestBuildHysteria2ProfileDistinguishesFalseFromMissing(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, "listen: \":443\"\nignoreClientBandwidth: false\n")
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.IgnoreClientBandwidth == nil {
|
||||||
|
t.Fatal("явное false прочитано как «не задано»")
|
||||||
|
}
|
||||||
|
if *profile.IgnoreClientBandwidth {
|
||||||
|
t.Fatal("значение искажено")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секции вне профиля перечисляются поимённо — это и есть диагностика дрейфа.
|
||||||
|
func TestBuildHysteria2ProfileReportsDrift(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, canonicalProfileConfig+`
|
||||||
|
masquerade:
|
||||||
|
type: proxy
|
||||||
|
proxy:
|
||||||
|
url: https://example.com
|
||||||
|
|
||||||
|
resolver:
|
||||||
|
type: udp
|
||||||
|
|
||||||
|
outbounds:
|
||||||
|
- name: upstream
|
||||||
|
type: direct
|
||||||
|
|
||||||
|
someFutureUpstreamFeature:
|
||||||
|
enabled: true
|
||||||
|
`)
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"masquerade", "outbounds", "resolver", "someFutureUpstreamFeature"}
|
||||||
|
if len(profile.Drift) != len(want) {
|
||||||
|
t.Fatalf("расхождения: %v, ожидалось %v", profile.Drift, want)
|
||||||
|
}
|
||||||
|
for i, section := range want {
|
||||||
|
if profile.Drift[i] != section {
|
||||||
|
t.Fatalf("расхождения перечислены не по порядку: %v", profile.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секция, о которой HY2XS не знает вовсе, обязана попасть в расхождения:
|
||||||
|
// типизированная модель её теряет, а именно такие секции и означают, что файл
|
||||||
|
// правил не оркестратор.
|
||||||
|
func TestBuildHysteria2ProfileReportsUnknownSections(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, "listen: \":443\"\ntotallyUnknownSection:\n a: 1\n")
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
if len(profile.Drift) != 1 || profile.Drift[0] != "totallyUnknownSection" {
|
||||||
|
t.Fatalf("неизвестная секция не названа расхождением: %v", profile.Drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ни один секрет не покидает сервер через читающий экран.
|
||||||
|
//
|
||||||
|
// Раньше `auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль
|
||||||
|
// обфускации, токены ACME DNS и учётные данные outbound/masquerade — нет.
|
||||||
|
// Скачиваемая выгрузка того же конфига их вырезает, и читающий экран не имеет
|
||||||
|
// права быть щедрее.
|
||||||
|
func TestBuildHysteria2ProfileCarriesNoSecrets(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, canonicalProfileConfig+`
|
||||||
|
outbounds:
|
||||||
|
- name: upstream
|
||||||
|
type: socks5
|
||||||
|
socks5:
|
||||||
|
addr: 10.0.0.1:1080
|
||||||
|
username: proxyuser
|
||||||
|
password: proxy-password
|
||||||
|
`)
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := profileJSON(t, profile)
|
||||||
|
for _, secret := range []string{
|
||||||
|
"gecko-obfs-secret",
|
||||||
|
"traffic-stats-secret",
|
||||||
|
"machine-secret",
|
||||||
|
"proxy-password",
|
||||||
|
} {
|
||||||
|
if strings.Contains(encoded, secret) {
|
||||||
|
t.Fatalf("секрет %q уехал в браузер:\n%s", secret, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// При этом диагностические факты сохранены: пароль задан, адрес auth
|
||||||
|
// известен.
|
||||||
|
if profile.Obfs == nil || !profile.Obfs.PasswordSet {
|
||||||
|
t.Fatalf("факт «пароль обфускации задан» потерян: %+v", profile.Obfs)
|
||||||
|
}
|
||||||
|
if profile.Auth == nil || profile.Auth.Url == nil ||
|
||||||
|
!strings.Contains(*profile.Auth.Url, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||||
|
t.Fatalf("адрес backend-auth потерян: %+v", profile.Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имена параметров ACME DNS диагностичны, значения — нет.
|
||||||
|
func TestBuildHysteria2ProfileShowsAcmeDnsKeysWithoutValues(t *testing.T) {
|
||||||
|
writeHysteriaConfig(t, `listen: ":443"
|
||||||
|
acme:
|
||||||
|
domains:
|
||||||
|
- vpn.example.com
|
||||||
|
type: dns
|
||||||
|
dns:
|
||||||
|
name: cloudflare
|
||||||
|
config:
|
||||||
|
cloudflare_api_token: super-secret-token
|
||||||
|
zone: example.com
|
||||||
|
`)
|
||||||
|
|
||||||
|
profile, err := BuildHysteria2Profile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("построение профиля отказало: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.Acme == nil || profile.Acme.DnsProvider == nil || *profile.Acme.DnsProvider != "cloudflare" {
|
||||||
|
t.Fatalf("провайдер DNS потерян: %+v", profile.Acme)
|
||||||
|
}
|
||||||
|
want := []string{"cloudflare_api_token", "zone"}
|
||||||
|
if len(profile.Acme.DnsConfigKeys) != len(want) {
|
||||||
|
t.Fatalf("имена параметров DNS: %v", profile.Acme.DnsConfigKeys)
|
||||||
|
}
|
||||||
|
for i, key := range want {
|
||||||
|
if profile.Acme.DnsConfigKeys[i] != key {
|
||||||
|
t.Fatalf("имена параметров DNS перечислены не по порядку: %v", profile.Acme.DnsConfigKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(profileJSON(t, profile), "super-secret-token") {
|
||||||
|
t.Fatal("значение параметра DNS уехало в браузер")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствие файла — отказ, а не пустой профиль: «конфига нет» и «конфиг пуст»
|
||||||
|
// для оператора разные ответы.
|
||||||
|
func TestBuildHysteria2ProfileFailsWithoutFile(t *testing.T) {
|
||||||
|
previous := constant.Hysteria2ConfigPath
|
||||||
|
constant.Hysteria2ConfigPath = filepath.Join(t.TempDir(), "missing.yaml")
|
||||||
|
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||||
|
|
||||||
|
if _, err := BuildHysteria2Profile(); err == nil {
|
||||||
|
t.Fatal("отсутствующий конфиг выдан за пустой профиль")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/vo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дашборд не имеет права утверждать физически несовместимые вещи.
|
||||||
|
//
|
||||||
|
// Что было:
|
||||||
|
//
|
||||||
|
// Hysteria stopped
|
||||||
|
// Traffic Stats API reachable
|
||||||
|
// 0 online users
|
||||||
|
//
|
||||||
|
// Все три строки выводились из ОДНОГО ответа systemctl: общий Hysteria2Online
|
||||||
|
// при неактивной службе отдавал пустую карту без ошибки, и сборщик метрик
|
||||||
|
// выставлял `apiReachable = true`, ни разу не сходив в API.
|
||||||
|
//
|
||||||
|
// Матрица ниже проверяет все четыре клетки: два источника, два исхода каждый.
|
||||||
|
|
||||||
|
func TestParseSystemdActiveState(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
want HysteriaServiceState
|
||||||
|
}{
|
||||||
|
{"active", "active\n", HysteriaServiceActive},
|
||||||
|
{"inactive", "inactive\n", HysteriaServiceInactive},
|
||||||
|
{"failed", "failed\n", HysteriaServiceInactive},
|
||||||
|
{"activating", "activating\n", HysteriaServiceInactive},
|
||||||
|
{"deactivating", "deactivating\n", HysteriaServiceInactive},
|
||||||
|
{"пробелы по краям", " active ", HysteriaServiceActive},
|
||||||
|
{"несколько строк", "active\nactive\n", HysteriaServiceActive},
|
||||||
|
{"пустой ответ", "", HysteriaServiceUnknown},
|
||||||
|
// Список состояний systemd пополнялся и раньше. Молчаливое приведение
|
||||||
|
// незнакомого слова к «остановлена» вернуло бы ровно ту ложь, ради
|
||||||
|
// которой заведено третье значение.
|
||||||
|
{"неизвестное состояние", "quantum\n", HysteriaServiceUnknown},
|
||||||
|
{"текст ошибки вместо состояния", "Failed to connect to bus\n", HysteriaServiceUnknown},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := parseSystemdActiveState(tc.output); got != tc.want {
|
||||||
|
t.Fatalf("parseSystemdActiveState(%q) = %q, ожидалось %q", tc.output, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клетка 1: служба активна, API отвечает.
|
||||||
|
func TestDashboardStateActiveServiceReachableApi(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||||
|
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||||
|
|
||||||
|
CollectMetricsSnapshot()
|
||||||
|
snapshot := DashboardSnapshot()
|
||||||
|
|
||||||
|
if snapshot.Hysteria.ServiceState != string(HysteriaServiceActive) || !snapshot.Hysteria.Running {
|
||||||
|
t.Fatalf("состояние службы: %+v", snapshot.Hysteria)
|
||||||
|
}
|
||||||
|
if !snapshot.Hysteria.ApiReachable {
|
||||||
|
t.Fatal("API отвечает, но объявлен недоступным")
|
||||||
|
}
|
||||||
|
if snapshot.OnlineDevices != 2 || snapshot.OnlinePeers != 1 {
|
||||||
|
t.Fatalf("картина подключений: пиров %d, устройств %d", snapshot.OnlinePeers, snapshot.OnlineDevices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клетка 2: служба неактивна, API молчит. Оба факта согласованы, и оба верны.
|
||||||
|
func TestDashboardStateInactiveServiceUnreachableApi(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
pointHysteriaConfigAtDeadPort(t)
|
||||||
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
|
|
||||||
|
CollectMetricsSnapshot()
|
||||||
|
snapshot := DashboardSnapshot()
|
||||||
|
|
||||||
|
if snapshot.Hysteria.ServiceState != string(HysteriaServiceInactive) {
|
||||||
|
t.Fatalf("состояние службы объявлено как %q", snapshot.Hysteria.ServiceState)
|
||||||
|
}
|
||||||
|
if snapshot.Hysteria.ApiReachable {
|
||||||
|
t.Fatal("недоступный Traffic Stats API объявлен доступным")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клетка 3 — главная регрессия: спросить systemctl не удалось, но Hysteria
|
||||||
|
// жива и API отвечает.
|
||||||
|
//
|
||||||
|
// Прежний путь показывал «служба остановлена» и «API доступен» одновременно,
|
||||||
|
// причём второе — не сходив в API. Теперь неизвестность названа неизвестностью,
|
||||||
|
// а доступность API получена фактическим обращением.
|
||||||
|
func TestDashboardStateUnknownServiceReachableApi(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||||
|
withHysteriaServiceState(t, HysteriaServiceUnknown)
|
||||||
|
|
||||||
|
CollectMetricsSnapshot()
|
||||||
|
snapshot := DashboardSnapshot()
|
||||||
|
|
||||||
|
if snapshot.Hysteria.ServiceState != string(HysteriaServiceUnknown) {
|
||||||
|
t.Fatalf("неизвестное состояние выдано за %q", snapshot.Hysteria.ServiceState)
|
||||||
|
}
|
||||||
|
if snapshot.Hysteria.Running {
|
||||||
|
t.Fatal("неизвестное состояние выдано за работающую службу")
|
||||||
|
}
|
||||||
|
if !snapshot.Hysteria.ApiReachable {
|
||||||
|
t.Fatal("мнение systemd подменило фактический ответ Traffic Stats API")
|
||||||
|
}
|
||||||
|
if snapshot.OnlineDevices != 1 {
|
||||||
|
t.Fatalf("картина подключений потеряна: устройств %d", snapshot.OnlineDevices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клетка 4: служба активна, но API отказывает — например, Hysteria ещё
|
||||||
|
// поднимается или порт занят чужим процессом.
|
||||||
|
func TestDashboardStateActiveServiceFailingApi(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
|
||||||
|
withHysteriaServiceState(t, HysteriaServiceActive)
|
||||||
|
|
||||||
|
CollectMetricsSnapshot()
|
||||||
|
snapshot := DashboardSnapshot()
|
||||||
|
|
||||||
|
if !snapshot.Hysteria.Running {
|
||||||
|
t.Fatal("активная служба объявлена остановленной")
|
||||||
|
}
|
||||||
|
if snapshot.Hysteria.ApiReachable {
|
||||||
|
t.Fatal("отказ API объявлен доступностью")
|
||||||
|
}
|
||||||
|
if snapshot.HysteriaState.Status != "error" {
|
||||||
|
t.Fatalf("отказ API не попал в состояние данных: %+v", snapshot.HysteriaState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Состояние неизвестно» и «служба остановлена» — разные сообщения оператору с
|
||||||
|
// разной срочностью и разными действиями.
|
||||||
|
func TestDashboardSecurityRisksDistinguishUnknownFromStopped(t *testing.T) {
|
||||||
|
riskKeys := func(state HysteriaServiceState, apiReachable bool) []string {
|
||||||
|
summary := vo.DashboardSummaryVo{}
|
||||||
|
summary.Hysteria.ServiceState = string(state)
|
||||||
|
summary.Hysteria.Running = state == HysteriaServiceActive
|
||||||
|
summary.Hysteria.ApiReachable = apiReachable
|
||||||
|
|
||||||
|
keys := make([]string, 0)
|
||||||
|
for _, risk := range DashboardSecurityRisks(summary) {
|
||||||
|
keys = append(keys, risk.Key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := riskKeys(HysteriaServiceActive, true); len(got) != 0 {
|
||||||
|
t.Fatalf("исправная система получила предупреждения: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := riskKeys(HysteriaServiceInactive, true)
|
||||||
|
if len(stopped) != 1 || stopped[0] != "dashboard.security.hysteriaStopped" {
|
||||||
|
t.Fatalf("остановленная служба: %v", stopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := riskKeys(HysteriaServiceUnknown, true)
|
||||||
|
if len(unknown) != 1 || unknown[0] != "dashboard.security.hysteriaStateUnknown" {
|
||||||
|
t.Fatalf("неизвестное состояние выдано за остановленную службу: %v", unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
both := riskKeys(HysteriaServiceInactive, false)
|
||||||
|
if len(both) != 2 {
|
||||||
|
t.Fatalf("два независимых отказа схлопнулись в один: %v", both)
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-23
@@ -4,19 +4,61 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hy2xs-admin/model/constant"
|
"math"
|
||||||
"hy2xs-admin/model/vo"
|
|
||||||
"hy2xs-admin/util"
|
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/model/vo"
|
||||||
|
"hy2xs-admin/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type journalctlLogLine struct {
|
type journalctlLogLine struct {
|
||||||
Message string `json:"MESSAGE"`
|
Message journalMessage `json:"MESSAGE"`
|
||||||
Priority string `json:"PRIORITY"`
|
Priority string `json:"PRIORITY"`
|
||||||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalMessage — поле MESSAGE журнала, которое journald отдаёт ДВУМЯ разными
|
||||||
|
// способами.
|
||||||
|
//
|
||||||
|
// Обычно это строка. Но если сообщение не является корректным UTF-8, `-o json`
|
||||||
|
// отдаёт его массивом байтовых значений — это документированное поведение
|
||||||
|
// journald, а не аномалия. Прежний `Message string` на такой записи ронял
|
||||||
|
// разбор ВСЕЙ строки, и она молча выпадала из журнала: обработчик делал
|
||||||
|
// `continue`. То есть ровно те строки, ради которых журнал открывают чаще
|
||||||
|
// всего — с испорченным выводом чужого процесса, — панель не показывала вовсе.
|
||||||
|
type journalMessage string
|
||||||
|
|
||||||
|
func (m *journalMessage) UnmarshalJSON(data []byte) error {
|
||||||
|
var asString string
|
||||||
|
if err := json.Unmarshal(data, &asString); err == nil {
|
||||||
|
*m = journalMessage(asString)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Массив чисел разбирается через []int, а не []byte: для []byte
|
||||||
|
// encoding/json ожидает base64-строку и на JSON-массиве откажет.
|
||||||
|
var asBytes []int
|
||||||
|
if err := json.Unmarshal(data, &asBytes); err == nil {
|
||||||
|
raw := make([]byte, 0, len(asBytes))
|
||||||
|
for _, value := range asBytes {
|
||||||
|
if value < 0 || value > 255 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw = append(raw, byte(value))
|
||||||
|
}
|
||||||
|
*m = journalMessage(raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле отсутствует или имеет неизвестную форму: сообщения нет, но сама
|
||||||
|
// запись остаётся — у неё есть время и приоритет.
|
||||||
|
*m = ""
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeJournalLineLimit(value int, fallback int, max int) int {
|
func normalizeJournalLineLimit(value int, fallback int, max int) int {
|
||||||
@@ -91,11 +133,39 @@ func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
|||||||
return SanitizeLogText(output), nil
|
return SanitizeLogText(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Структурный журнал Hysteria разбирается ПО ФАКТИЧЕСКОМУ формату 2.12.2.
|
||||||
|
//
|
||||||
|
// Что было. Запись складывалась прямым json.Unmarshal в vo.LogHysteria2Vo, у
|
||||||
|
// которого `Time string`. JSON-логгер Hysteria объявлен так:
|
||||||
|
//
|
||||||
|
// TimeKey: "time", LevelKey: "level", MessageKey: "msg",
|
||||||
|
// EncodeTime: zapcore.EpochMillisTimeEncoder
|
||||||
|
//
|
||||||
|
// то есть `time` — это ЧИСЛО, причём дробное: энкодер делит наносекунды на
|
||||||
|
// миллисекунду и печатает float64 (`1788321234567.1235`). Разбор падал на
|
||||||
|
// каждой без исключения строке:
|
||||||
|
//
|
||||||
|
// cannot unmarshal number into Go struct field ... of type string
|
||||||
|
//
|
||||||
|
// После чего срабатывал fallback и панель показывала сырой JSON целиком. То
|
||||||
|
// есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а структурой никто не
|
||||||
|
// пользовался: страница журнала выглядела ровно так же, как без него, только
|
||||||
|
// менее читаемо.
|
||||||
|
//
|
||||||
|
// Замена типа поля на int64 проблему НЕ решает по двум причинам: значение
|
||||||
|
// дробное, а кроме level/time/msg upstream кладёт в запись диагностический
|
||||||
|
// контекст (`listen`, `addr`, `id`, `error`, `tx`, `rx`, …), который при
|
||||||
|
// разборе в три известных поля просто выбрасывался бы. Именно эти поля и
|
||||||
|
// отвечают на вопрос, ради которого журнал открывают.
|
||||||
|
//
|
||||||
|
// Поэтому разбор идёт через map: известные ключи заполняют колонки, остальные
|
||||||
|
// дописываются к сообщению как `msg [key=value …]` в устойчивом порядке.
|
||||||
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||||
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
|
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
|
||||||
fallbackLevel := mapJournalPriorityToLevel(item.Priority)
|
fallbackLevel := mapJournalPriorityToLevel(item.Priority)
|
||||||
|
message := string(item.Message)
|
||||||
|
|
||||||
if strings.TrimSpace(item.Message) == "" {
|
if strings.TrimSpace(message) == "" {
|
||||||
return vo.LogHysteria2Vo{
|
return vo.LogHysteria2Vo{
|
||||||
Level: fallbackLevel,
|
Level: fallbackLevel,
|
||||||
Msg: "",
|
Msg: "",
|
||||||
@@ -103,25 +173,127 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed := vo.LogHysteria2Vo{}
|
fields := map[string]any{}
|
||||||
if err := json.Unmarshal([]byte(item.Message), &parsed); err == nil {
|
if err := json.Unmarshal([]byte(message), &fields); err != nil {
|
||||||
if parsed.Level == "" {
|
// Не JSON вовсе: журнал Hysteria в console-формате либо посторонняя
|
||||||
parsed.Level = fallbackLevel
|
// строка от рантайма. Показывается как есть.
|
||||||
|
return vo.LogHysteria2Vo{
|
||||||
|
Level: fallbackLevel,
|
||||||
|
Msg: SanitizeLogText(message),
|
||||||
|
Time: fallbackTime,
|
||||||
}
|
}
|
||||||
if parsed.Time == "" {
|
|
||||||
parsed.Time = fallbackTime
|
|
||||||
}
|
|
||||||
if parsed.Msg == "" {
|
|
||||||
parsed.Msg = item.Message
|
|
||||||
}
|
|
||||||
parsed.Msg = SanitizeLogText(parsed.Msg)
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return vo.LogHysteria2Vo{
|
record := vo.LogHysteria2Vo{
|
||||||
Level: fallbackLevel,
|
Level: journalStringField(fields, "level"),
|
||||||
Msg: SanitizeLogText(item.Message),
|
Msg: journalStringField(fields, "msg"),
|
||||||
Time: fallbackTime,
|
Time: hysteriaLogTime(fields["time"]),
|
||||||
|
}
|
||||||
|
if record.Level == "" {
|
||||||
|
record.Level = fallbackLevel
|
||||||
|
}
|
||||||
|
if record.Time == "" {
|
||||||
|
// Время journald — канонический запасной вариант: оно есть у КАЖДОЙ
|
||||||
|
// записи и относится к тому же событию.
|
||||||
|
record.Time = fallbackTime
|
||||||
|
}
|
||||||
|
if record.Msg == "" {
|
||||||
|
// JSON без `msg` — не запись Hysteria. Терять её нельзя.
|
||||||
|
record.Msg = message
|
||||||
|
}
|
||||||
|
record.Msg = SanitizeLogText(record.Msg + hysteriaLogContext(fields))
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteriaLogReservedFields — ключи, которые уже показаны собственными
|
||||||
|
// колонками либо не несут смысла для оператора.
|
||||||
|
//
|
||||||
|
// `logger` объявлен в EncoderConfig Hysteria (NameKey), но именованных
|
||||||
|
// логгеров у сервера нет, поэтому в записи он не появляется; ключ перечислен,
|
||||||
|
// чтобы будущее появление имени не поехало в текст сообщения.
|
||||||
|
var hysteriaLogReservedFields = map[string]struct{}{
|
||||||
|
"level": {},
|
||||||
|
"msg": {},
|
||||||
|
"time": {},
|
||||||
|
"logger": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteriaLogContext собирает структурный контекст записи в устойчивом порядке.
|
||||||
|
//
|
||||||
|
// Порядок ключей в map случаен, поэтому без сортировки одна и та же запись
|
||||||
|
// выглядела бы по-разному при каждом обновлении страницы.
|
||||||
|
func hysteriaLogContext(fields map[string]any) string {
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for key := range fields {
|
||||||
|
if _, reserved := hysteriaLogReservedFields[key]; reserved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s", key, journalFieldValue(fields[key])))
|
||||||
|
}
|
||||||
|
return " [" + strings.Join(parts, " ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalFieldValue приводит значение поля к однострочному виду.
|
||||||
|
func journalFieldValue(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(typed)
|
||||||
|
case float64:
|
||||||
|
return formatJSONNumber(typed)
|
||||||
|
default:
|
||||||
|
// Вложенный объект или массив: JSON компактнее и однозначнее, чем
|
||||||
|
// проценты Go-форматирования.
|
||||||
|
encoded, err := json.Marshal(typed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", typed)
|
||||||
|
}
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatJSONNumber печатает число без экспоненты и без хвоста `.0`: в журнале
|
||||||
|
// оператор ожидает `443`, а не `4.43e+02`.
|
||||||
|
func formatJSONNumber(value float64) string {
|
||||||
|
if value == math.Trunc(value) && math.Abs(value) < 1e15 {
|
||||||
|
return strconv.FormatInt(int64(value), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalStringField(fields map[string]any, key string) string {
|
||||||
|
if value, ok := fields[key].(string); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hysteriaLogTime переводит `time` записи в отображаемое время.
|
||||||
|
//
|
||||||
|
// Число трактуется как epoch millis — так его пишет EpochMillisTimeEncoder.
|
||||||
|
// Строка принимается как есть: console-формат и будущая смена энкодера дают
|
||||||
|
// RFC3339, и переписывать его продукт не обязан.
|
||||||
|
func hysteriaLogTime(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
millis := int64(typed)
|
||||||
|
return time.UnixMilli(millis).Format("2006-01-02 15:04:05")
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Разбор журнала проверяется на ФАКТИЧЕСКОМ формате Hysteria 2.12.2.
|
||||||
|
//
|
||||||
|
// Записи в тестах не выдуманы: они собираются тем же способом, каким их пишет
|
||||||
|
// zap с EncoderConfig upstream — `time` числом epoch millis (дробным, потому
|
||||||
|
// что EpochMillisTimeEncoder делит наносекунды на миллисекунду), `level`
|
||||||
|
// строчными буквами, `msg` и произвольный структурный контекст рядом.
|
||||||
|
//
|
||||||
|
// Прежний разбор падал на КАЖДОЙ такой строке (`cannot unmarshal number into
|
||||||
|
// Go struct field ... of type string`) и уходил в fallback, показывая оператору
|
||||||
|
// сырой JSON. То есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а
|
||||||
|
// структурой никто не пользовался.
|
||||||
|
|
||||||
|
// hysteriaJSONLine собирает запись так же, как её пишет zap Hysteria.
|
||||||
|
func hysteriaJSONLine(t *testing.T, fields map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось собрать запись журнала: %v", err)
|
||||||
|
}
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalLine(message string) journalctlLogLine {
|
||||||
|
return journalctlLogLine{
|
||||||
|
Message: journalMessage(message),
|
||||||
|
Priority: "6",
|
||||||
|
RealtimeTimestamp: "1788321234567000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главная регрессия: числовое `time` больше не роняет разбор.
|
||||||
|
func TestParseHysteriaJournalRecordReadsNumericEpochMillis(t *testing.T) {
|
||||||
|
// Значение дробное намеренно: EpochMillisTimeEncoder печатает float64.
|
||||||
|
moment := time.Date(2026, 9, 2, 12, 34, 56, 789_012_345, time.Local)
|
||||||
|
millis := float64(moment.UnixNano()) / float64(time.Millisecond)
|
||||||
|
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": millis,
|
||||||
|
"level": "info",
|
||||||
|
"msg": "server up and running",
|
||||||
|
})))
|
||||||
|
|
||||||
|
if record.Level != "info" {
|
||||||
|
t.Fatalf("уровень %q, ожидался info", record.Level)
|
||||||
|
}
|
||||||
|
if record.Msg != "server up and running" {
|
||||||
|
t.Fatalf("сообщение %q, ожидался разобранный msg", record.Msg)
|
||||||
|
}
|
||||||
|
want := moment.Format("2006-01-02 15:04:05")
|
||||||
|
if record.Time != want {
|
||||||
|
t.Fatalf("время %q, ожидалось %q", record.Time, want)
|
||||||
|
}
|
||||||
|
// Сырой JSON в сообщение не попадает: это и был прежний исход.
|
||||||
|
if strings.Contains(record.Msg, "{") {
|
||||||
|
t.Fatalf("в сообщение уехала сырая запись: %q", record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Структурный контекст upstream сохраняется: именно он отвечает на вопрос,
|
||||||
|
// ради которого журнал открывают.
|
||||||
|
func TestParseHysteriaJournalRecordKeepsStructuredContext(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": 1788321234567.1235,
|
||||||
|
"level": "error",
|
||||||
|
"msg": "authentication failed",
|
||||||
|
"addr": "203.0.113.10:51820",
|
||||||
|
"id": "alpha-auth-id",
|
||||||
|
"error": "device limited",
|
||||||
|
"listen": ":443",
|
||||||
|
})))
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"authentication failed",
|
||||||
|
"addr=203.0.113.10:51820",
|
||||||
|
"error=device limited",
|
||||||
|
"id=alpha-auth-id",
|
||||||
|
"listen=:443",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(record.Msg, want) {
|
||||||
|
t.Fatalf("контекст %q потерян: %q", want, record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок полей устойчив: карта в Go обходится случайно, и без сортировки одна
|
||||||
|
// и та же запись выглядела бы по-разному при каждом обновлении страницы.
|
||||||
|
func TestParseHysteriaJournalRecordContextOrderIsStable(t *testing.T) {
|
||||||
|
line := journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": 1788321234567.0,
|
||||||
|
"level": "info",
|
||||||
|
"msg": "client connected",
|
||||||
|
"zulu": "z",
|
||||||
|
"alpha": "a",
|
||||||
|
"mike": "m",
|
||||||
|
}))
|
||||||
|
|
||||||
|
first := parseHysteriaJournalRecord(line).Msg
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if got := parseHysteriaJournalRecord(line).Msg; got != first {
|
||||||
|
t.Fatalf("порядок контекста не устойчив: %q против %q", got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(first, "[alpha=a mike=m zulu=z]") {
|
||||||
|
t.Fatalf("контекст собран не по алфавиту: %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числа контекста печатаются человеку, а не в экспоненте.
|
||||||
|
func TestParseHysteriaJournalRecordFormatsNumbers(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": 1788321234567.0,
|
||||||
|
"level": "info",
|
||||||
|
"msg": "traffic",
|
||||||
|
"tx": float64(1_234_567_890),
|
||||||
|
"ratio": 0.5,
|
||||||
|
"ok": true,
|
||||||
|
})))
|
||||||
|
|
||||||
|
for _, want := range []string{"tx=1234567890", "ratio=0.5", "ok=true"} {
|
||||||
|
if !strings.Contains(record.Msg, want) {
|
||||||
|
t.Fatalf("значение %q отформатировано неверно: %q", want, record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вложенный объект остаётся однострочным и читаемым.
|
||||||
|
func TestParseHysteriaJournalRecordFlattensNestedFields(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": 1788321234567.0,
|
||||||
|
"level": "warn",
|
||||||
|
"msg": "acl reloaded",
|
||||||
|
"stats": map[string]any{"rules": float64(12)},
|
||||||
|
})))
|
||||||
|
|
||||||
|
if !strings.Contains(record.Msg, `stats={"rules":12}`) {
|
||||||
|
t.Fatalf("вложенный объект потерян: %q", record.Msg)
|
||||||
|
}
|
||||||
|
if strings.Contains(record.Msg, "\n") {
|
||||||
|
t.Fatalf("запись журнала стала многострочной: %q", record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секреты вырезаются и из сообщения, и из структурного контекста: HY2_AUTH_URL
|
||||||
|
// несёт access_token, и upstream волен упомянуть его в тексте ошибки.
|
||||||
|
func TestParseHysteriaJournalRecordSanitizesSecrets(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": 1788321234567.0,
|
||||||
|
"level": "error",
|
||||||
|
"msg": "auth backend request failed",
|
||||||
|
"url": "http://127.0.0.1:8080/internal/hysteria/auth?access_token=super-secret-value",
|
||||||
|
})))
|
||||||
|
|
||||||
|
if strings.Contains(record.Msg, "super-secret-value") {
|
||||||
|
t.Fatalf("секрет уехал в журнал панели: %q", record.Msg)
|
||||||
|
}
|
||||||
|
// Внутри query маркер приезжает percent-кодированным: URL обязан остаться
|
||||||
|
// валидным URL (см. encodedRedactedPlaceholder в log_sanitize.go).
|
||||||
|
if !strings.Contains(record.Msg, RedactedPlaceholder) &&
|
||||||
|
!strings.Contains(record.Msg, encodedRedactedPlaceholder) {
|
||||||
|
t.Fatalf("секрет вырезан без маркера: %q", record.Msg)
|
||||||
|
}
|
||||||
|
// Остальная часть адреса остаётся читаемой: без неё запись перестаёт быть
|
||||||
|
// диагностикой.
|
||||||
|
if !strings.Contains(record.Msg, "/internal/hysteria/auth") {
|
||||||
|
t.Fatalf("адрес вырезан целиком вместо секрета: %q", record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не-JSON строка показывается как есть: console-формат и посторонний вывод
|
||||||
|
// рантайма терять нельзя.
|
||||||
|
func TestParseHysteriaJournalRecordFallsBackToRawMessage(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine("panic: runtime error: invalid memory address"))
|
||||||
|
|
||||||
|
if record.Msg != "panic: runtime error: invalid memory address" {
|
||||||
|
t.Fatalf("сырое сообщение потеряно: %q", record.Msg)
|
||||||
|
}
|
||||||
|
// Время и уровень берутся у journald: у него они есть всегда.
|
||||||
|
if record.Time == "" {
|
||||||
|
t.Fatal("время journald не подставлено")
|
||||||
|
}
|
||||||
|
if record.Level != "info" {
|
||||||
|
t.Fatalf("уровень journald не подставлен: %q", record.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON без `msg` — не запись Hysteria, и терять её тоже нельзя.
|
||||||
|
func TestParseHysteriaJournalRecordKeepsForeignJSON(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(`{"foo":"bar"}`))
|
||||||
|
|
||||||
|
if !strings.Contains(record.Msg, "foo") {
|
||||||
|
t.Fatalf("посторонний JSON потерян: %q", record.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись без собственного времени и уровня добирает их у journald.
|
||||||
|
func TestParseHysteriaJournalRecordUsesJournaldFallbacks(t *testing.T) {
|
||||||
|
line := journalLine(hysteriaJSONLine(t, map[string]any{"msg": "no level, no time"}))
|
||||||
|
line.Priority = "3"
|
||||||
|
|
||||||
|
record := parseHysteriaJournalRecord(line)
|
||||||
|
|
||||||
|
if record.Level != "error" {
|
||||||
|
t.Fatalf("уровень не взят из PRIORITY: %q", record.Level)
|
||||||
|
}
|
||||||
|
if record.Time != convertJournalTimestamp(line.RealtimeTimestamp) {
|
||||||
|
t.Fatalf("время не взято из __REALTIME_TIMESTAMP: %q", record.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строковое `time` (console-формат, будущая смена энкодера) принимается как
|
||||||
|
// есть: переписывать чужой формат продукт не обязан.
|
||||||
|
func TestParseHysteriaJournalRecordAcceptsStringTime(t *testing.T) {
|
||||||
|
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
|
||||||
|
"time": "2026-09-02T12:34:56Z",
|
||||||
|
"level": "info",
|
||||||
|
"msg": "server up and running",
|
||||||
|
})))
|
||||||
|
|
||||||
|
if record.Time != "2026-09-02T12:34:56Z" {
|
||||||
|
t.Fatalf("строковое время подменено: %q", record.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MESSAGE, отданный journald массивом байт (сообщение не является корректным
|
||||||
|
// UTF-8), больше не выбрасывает запись целиком.
|
||||||
|
func TestJournalMessageAcceptsByteArray(t *testing.T) {
|
||||||
|
var line journalctlLogLine
|
||||||
|
// Так journald кодирует «hi\xff» в `-o json`.
|
||||||
|
raw := `{"MESSAGE":[104,105,255],"PRIORITY":"6","__REALTIME_TIMESTAMP":"1788321234567000"}`
|
||||||
|
if err := json.Unmarshal([]byte(raw), &line); err != nil {
|
||||||
|
t.Fatalf("запись с байтовым MESSAGE отвергнута целиком: %v", err)
|
||||||
|
}
|
||||||
|
if string(line.Message) != "hi\xff" {
|
||||||
|
t.Fatalf("байтовое сообщение разобрано неверно: %q", string(line.Message))
|
||||||
|
}
|
||||||
|
if line.Priority != "6" {
|
||||||
|
t.Fatalf("остальные поля записи потеряны: %+v", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalMessageAcceptsString(t *testing.T) {
|
||||||
|
var line journalctlLogLine
|
||||||
|
if err := json.Unmarshal([]byte(`{"MESSAGE":"plain","PRIORITY":"4"}`), &line); err != nil {
|
||||||
|
t.Fatalf("обычная запись отвергнута: %v", err)
|
||||||
|
}
|
||||||
|
if string(line.Message) != "plain" {
|
||||||
|
t.Fatalf("сообщение разобрано неверно: %q", string(line.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствующее или неизвестной формы MESSAGE не роняет запись: у неё остаются
|
||||||
|
// время и приоритет.
|
||||||
|
func TestJournalMessageSurvivesUnknownShape(t *testing.T) {
|
||||||
|
var line journalctlLogLine
|
||||||
|
if err := json.Unmarshal([]byte(`{"MESSAGE":{"unexpected":true},"PRIORITY":"6"}`), &line); err != nil {
|
||||||
|
t.Fatalf("запись с неизвестной формой MESSAGE отвергнута: %v", err)
|
||||||
|
}
|
||||||
|
if string(line.Message) != "" {
|
||||||
|
t.Fatalf("неизвестная форма превратилась в сообщение: %q", string(line.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,16 @@ func CollectMetricsSnapshot() {
|
|||||||
s.System.DiskTotalBytes = diskInfo.Total
|
s.System.DiskTotalBytes = diskInfo.Total
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Hysteria.Running = Hysteria2IsRunning()
|
// Два независимых источника, два независимых вопроса.
|
||||||
|
//
|
||||||
|
// Раньше второй выводился из первого: общий Hysteria2Online отдавал пустую
|
||||||
|
// карту БЕЗ ошибки, когда systemctl говорил «служба неактивна», и сюда
|
||||||
|
// приходило `err == nil` — то есть `ApiReachable = true` выставлялся, ни
|
||||||
|
// разу не сходив в Traffic Stats API. Дашборд утверждал «Hysteria
|
||||||
|
// остановлена» и «API доступен, онлайн 0» одновременно.
|
||||||
|
serviceState := Hysteria2ServiceState()
|
||||||
|
s.Hysteria.ServiceState = string(serviceState)
|
||||||
|
s.Hysteria.Running = serviceState == HysteriaServiceActive
|
||||||
s.Hysteria.Version = getCachedHysteriaVersion(time.Now())
|
s.Hysteria.Version = getCachedHysteriaVersion(time.Now())
|
||||||
|
|
||||||
if onlineMap, err := Hysteria2Online(); err == nil {
|
if onlineMap, err := Hysteria2Online(); err == nil {
|
||||||
|
|||||||
+98
-22
@@ -14,15 +14,33 @@ import (
|
|||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"hy2xs-admin/model/vo"
|
"hy2xs-admin/model/vo"
|
||||||
"hy2xs-admin/util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
// PagePeer отдаёт страницу списка пиров и ЧЕСТНО сообщает, известна ли картина
|
||||||
|
// подключений.
|
||||||
|
//
|
||||||
|
// Что было: `onlineUsers, _ := Hysteria2Online()`. Отказ Traffic Stats API
|
||||||
|
// молча превращался в пустую карту, и оператор видел «все офлайн» вместо
|
||||||
|
// «состояние неизвестно». Признак возвращается один на страницу — см.
|
||||||
|
// vo.PeerPageVo.
|
||||||
|
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, string, error) {
|
||||||
peers, total, err := dao.PagePeer(peerPageDto)
|
peers, total, err := dao.PagePeer(peerPageDto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
onlineUsers, _ := Hysteria2Online()
|
|
||||||
|
onlineState := vo.PeerOnlineStateOk
|
||||||
|
onlineUsers, onlineErr := Hysteria2Online()
|
||||||
|
if onlineErr != nil {
|
||||||
|
// Отказ здесь не является отказом страницы: список пиров и их
|
||||||
|
// сохранённое состояние известны и полезны сами по себе. Неизвестной
|
||||||
|
// становится ровно одна колонка, и об этом сказано явно.
|
||||||
|
logrus.WithError(onlineErr).
|
||||||
|
Warn("список пиров: картина подключений недоступна, признак online не определён")
|
||||||
|
onlineState = vo.PeerOnlineStateUnavailable
|
||||||
|
onlineUsers = map[string]int64{}
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]vo.PeerVo, 0, len(peers))
|
result := make([]vo.PeerVo, 0, len(peers))
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
item := vo.PeerVo{
|
item := vo.PeerVo{
|
||||||
@@ -46,7 +64,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
|||||||
}
|
}
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
return result, total, nil
|
return result, total, onlineState, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
|
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
|
||||||
@@ -97,7 +115,7 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return vo.PeerVo{}, err
|
return vo.PeerVo{}, err
|
||||||
}
|
}
|
||||||
authId, err := util.RandomString(18)
|
authId, err := newPeerAuthID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vo.PeerVo{}, err
|
return vo.PeerVo{}, err
|
||||||
}
|
}
|
||||||
@@ -145,17 +163,33 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
|||||||
if peerDto.Name != nil && *peerDto.Name != "" {
|
if peerDto.Name != nil && *peerDto.Name != "" {
|
||||||
updates["name"] = *peerDto.Name
|
updates["name"] = *peerDto.Name
|
||||||
}
|
}
|
||||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
if secret, requested := requestedSecret(peerDto.Secret); requested {
|
||||||
digest, err := PeerSecretDigest(*peerDto.Secret)
|
digest, err := PeerSecretDigest(secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
enc, err := EncryptPeerSecret(*peerDto.Secret)
|
enc, err := EncryptPeerSecret(secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
updates["secret_digest"] = digest
|
updates["secret_digest"] = digest
|
||||||
updates["secret_ciphertext"] = enc
|
updates["secret_ciphertext"] = enc
|
||||||
|
|
||||||
|
// Новое поколение учётных данных получает новую идентичность живых
|
||||||
|
// сессий. Без этого сессия, установленная по уже отозванному секрету,
|
||||||
|
// оставалась неотличимой от законной: `auth_id` у неё прежний, пир в
|
||||||
|
// базе есть, доступ открыт — и cron не имел ни одного признака, по
|
||||||
|
// которому её следовало бы завершить. Разбор — в peer_secret.go.
|
||||||
|
//
|
||||||
|
// Разрыв при этом идёт по СТАРОМУ значению (см. ниже): именно им
|
||||||
|
// Hysteria знает сессию, которую отзывает оператор.
|
||||||
|
if credentialGenerationChanged(before.SecretDigest, digest) {
|
||||||
|
rotatedAuthID, err := newPeerAuthID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["auth_id"] = rotatedAuthID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if peerDto.QuotaBytes != nil {
|
if peerDto.QuotaBytes != nil {
|
||||||
updates["quota_bytes"] = *peerDto.QuotaBytes
|
updates["quota_bytes"] = *peerDto.QuotaBytes
|
||||||
@@ -220,7 +254,12 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
|||||||
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
|
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
|
||||||
// Смена секрета. Учётные данные, по которым сессия была установлена, с
|
// Смена секрета. Учётные данные, по которым сессия была установлена, с
|
||||||
// этого момента недействительны — держать её открытой нечем.
|
// этого момента недействительны — держать её открытой нечем.
|
||||||
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
|
//
|
||||||
|
// Условие смотрит на ЗАПРОС, а не на смену поколения credentials: повтор
|
||||||
|
// того же секрета после неудавшегося разрыва обязан дать вторую попытку,
|
||||||
|
// ровно как повторное «Отключить». Ротация `auth_id` при этом не
|
||||||
|
// выполняется — см. credentialGenerationChanged.
|
||||||
|
if _, requested := requestedSecret(peerDto.Secret); requested {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +403,10 @@ func assertBootstrapPeerIdentityUnchanged(existing entity.Peer, peerDto dto.Peer
|
|||||||
//
|
//
|
||||||
// Теперь:
|
// Теперь:
|
||||||
//
|
//
|
||||||
// 1. прочитать пира и запомнить его auth ID;
|
// 1. прочитать пира и запомнить его auth ID;
|
||||||
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
|
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
|
||||||
// 3. завершить живые сессии по запомненному auth ID;
|
// 3. завершить живые сессии по запомненному auth ID;
|
||||||
// 4. удалить строку.
|
// 4. удалить строку.
|
||||||
//
|
//
|
||||||
// Ключевые исходы:
|
// Ключевые исходы:
|
||||||
//
|
//
|
||||||
@@ -647,7 +686,7 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
|||||||
|
|
||||||
entry.createAuthID = authID
|
entry.createAuthID = authID
|
||||||
if entry.createAuthID == "" {
|
if entry.createAuthID == "" {
|
||||||
generated, err := util.RandomString(18)
|
generated, err := newPeerAuthID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -695,13 +734,13 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
|||||||
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
|
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
|
||||||
// по семи access-полям:
|
// по семи access-полям:
|
||||||
//
|
//
|
||||||
// 1. импорт и так переписывает состояние целиком;
|
// 1. импорт и так переписывает состояние целиком;
|
||||||
// 2. старый auth ID гарантированно нужен при его замене;
|
// 2. старый auth ID гарантированно нужен при его замене;
|
||||||
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
|
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
|
||||||
// попытаться завершить старые сессии;
|
// попытаться завершить старые сессии;
|
||||||
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
|
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
|
||||||
// access-changing» — то есть второго места, где политика может разойтись
|
// access-changing» — то есть второго места, где политика может разойтись
|
||||||
// с peerAccessDenied.
|
// с peerAccessDenied.
|
||||||
//
|
//
|
||||||
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
|
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
|
||||||
// Для административной операции переноса это нормальная цена.
|
// Для административной операции переноса это нормальная цена.
|
||||||
@@ -781,6 +820,31 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
|
|||||||
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
|
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
|
||||||
// файла, а Hysteria знает пира по прежнему.
|
// файла, а Hysteria знает пира по прежнему.
|
||||||
replaced := authIDOf(existing)
|
replaced := authIDOf(existing)
|
||||||
|
|
||||||
|
// Импорт — вторая дверь к смене учётных данных, и правило поколения у
|
||||||
|
// неё то же самое: новый секрет получает новую идентичность сессий.
|
||||||
|
//
|
||||||
|
// Дверь эта закрывалась отдельно, потому что здесь состояние приходит
|
||||||
|
// из файла. Случай, который проходил мимо: запись найдена по ИМЕНИ либо
|
||||||
|
// несёт прежний `auth_id`, а секрет в файле новый. Тогда `auth_id`
|
||||||
|
// оставался прежним, и сессия, установленная по отозванному секрету,
|
||||||
|
// снова выглядела законной — тот же дефект, что и в UpdatePeer.
|
||||||
|
//
|
||||||
|
// Значение `auth_id` из файла при этом перебивается, и это осознанно:
|
||||||
|
// в импорте оно является КЛЮЧОМ ПОИСКА и переносимым состоянием, а не
|
||||||
|
// обещанием сохранить идентичность отозванного поколения. Если файл
|
||||||
|
// несёт ДРУГОЙ `auth_id`, ротация не нужна — идентичность уже сменилась
|
||||||
|
// им самим, и старая сессия становится orphan без нашего участия.
|
||||||
|
if entry.hasExplicitSecret &&
|
||||||
|
credentialGenerationChanged(existing.SecretDigest, entry.explicitDigest) &&
|
||||||
|
resultingAuthID(entry, replaced) == replaced {
|
||||||
|
rotated, err := newPeerAuthID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
updates["auth_id"] = rotated
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
|
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -821,6 +885,18 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
|
|||||||
return "", saveErr
|
return "", saveErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resultingAuthID — значение, которое окажется в строке после применения
|
||||||
|
// записи импорта, если ротации не будет.
|
||||||
|
//
|
||||||
|
// Файл волен не нести `auth_id` вовсе: тогда поиск шёл по имени, и прежнее
|
||||||
|
// значение остаётся.
|
||||||
|
func resultingAuthID(entry preparedPeerImport, existing string) string {
|
||||||
|
if entry.authID != "" {
|
||||||
|
return entry.authID
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
// findPeerForImport ищет запись, которую импорт должен обновить.
|
// findPeerForImport ищет запись, которую импорт должен обновить.
|
||||||
//
|
//
|
||||||
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
|
"hy2xs-admin/model/vo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
|
||||||
@@ -183,13 +184,13 @@ func pointHysteriaConfigAtDeadPort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
|
// withHysteriaServiceState подменяет ответ systemd о состоянии службы.
|
||||||
func withHysteriaRunning(t *testing.T, running bool) {
|
func withHysteriaServiceState(t *testing.T, state HysteriaServiceState) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
previous := hysteria2IsRunning
|
previous := hysteriaServiceState
|
||||||
hysteria2IsRunning = func() bool { return running }
|
hysteriaServiceState = func() HysteriaServiceState { return state }
|
||||||
t.Cleanup(func() { hysteria2IsRunning = previous })
|
t.Cleanup(func() { hysteriaServiceState = previous })
|
||||||
}
|
}
|
||||||
|
|
||||||
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
|
||||||
@@ -351,7 +352,7 @@ func TestDisconnectAuthIDsReportsApiFailure(t *testing.T) {
|
|||||||
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
|
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
stub := startTrafficStats(t, nil)
|
stub := startTrafficStats(t, nil)
|
||||||
withHysteriaRunning(t, false)
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
|
|
||||||
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
|
||||||
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
|
||||||
@@ -576,7 +577,7 @@ func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
|
|||||||
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
|
||||||
withHysteriaRunning(t, false)
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
|
||||||
@@ -648,19 +649,79 @@ func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
|
// Путь отображения БОЛЬШЕ НЕ выдаёт «никто не подключён» за ответ.
|
||||||
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
|
//
|
||||||
// только там, где принимается решение о доступе.
|
// Прежний контракт был терпимым: при неактивной по мнению systemd службе
|
||||||
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
|
// возвращалась пустая карта без ошибки. Выглядело это как честный ответ «сейчас
|
||||||
|
// никого нет», а означало «спросить не удалось либо не спрашивали вовсе» —
|
||||||
|
// ответ systemctl не отличает «служба неактивна» от «systemctl не запустился».
|
||||||
|
//
|
||||||
|
// Цена была не в формулировке. Сборщик метрик получал `err == nil` и выставлял
|
||||||
|
// `apiReachable = true`, ни разу не обратившись к Traffic Stats API, поэтому
|
||||||
|
// дашборд утверждал одновременно «Hysteria остановлена» и «API доступен, онлайн
|
||||||
|
// 0». Список пиров по тому же ответу показывал всех офлайн.
|
||||||
|
//
|
||||||
|
// Теперь недоступность остаётся ошибкой, а как показать её оператору, решает
|
||||||
|
// вызывающий — см. TestPagePeerReportsUnknownOnlineState.
|
||||||
|
func TestHysteria2OnlineReportsFailureInsteadOfEmptyPicture(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
pointHysteriaConfigAtDeadPort(t)
|
pointHysteriaConfigAtDeadPort(t)
|
||||||
withHysteriaRunning(t, false)
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
|
|
||||||
|
if _, err := Hysteria2Online(); err == nil {
|
||||||
|
t.Fatal("недоступность Traffic Stats API выдана за пустую картину подключений")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мнение systemd на картину подключений не влияет вовсе: спрашивают того, кто
|
||||||
|
// знает ответ.
|
||||||
|
func TestHysteria2OnlineIgnoresSystemdOpinion(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||||
|
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||||
|
|
||||||
online, err := Hysteria2Online()
|
online, err := Hysteria2Online()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("отображение подключений стало отказывать: %v", err)
|
t.Fatalf("картина подключений отказала из-за мнения systemd: %v", err)
|
||||||
}
|
}
|
||||||
if len(online) != 0 {
|
if online["alpha-auth-id"] != 2 {
|
||||||
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
|
t.Fatalf("мнение systemd подменило фактический ответ API: %v", online)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список пиров при недоступном API сообщает «состояние неизвестно», а не «все
|
||||||
|
// офлайн»: это разные ответы, и первый в аварии ведёт оператора искать
|
||||||
|
// проблему у пользователей.
|
||||||
|
func TestPagePeerReportsUnknownOnlineState(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
pointHysteriaConfigAtDeadPort(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
records, total, onlineState, err := PagePeer(dto.PeerPageDto{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("список пиров отказал из-за недоступного Traffic Stats API: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(records) != 1 {
|
||||||
|
t.Fatalf("список пиров потерян: записей %d, всего %d", len(records), total)
|
||||||
|
}
|
||||||
|
if onlineState != vo.PeerOnlineStateUnavailable {
|
||||||
|
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPagePeerReportsKnownOnlineState(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
records, _, onlineState, err := PagePeer(dto.PeerPageDto{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("список пиров отказал: %v", err)
|
||||||
|
}
|
||||||
|
if onlineState != vo.PeerOnlineStateOk {
|
||||||
|
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateOk)
|
||||||
|
}
|
||||||
|
if len(records) != 1 || !records[0].Online || records[0].OnlineDevices != 2 {
|
||||||
|
t.Fatalf("картина подключений не доехала до строки: %+v", records)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,104 @@ import (
|
|||||||
// из-за которого каждая первая загрузка печатала в журнал
|
// из-за которого каждая первая загрузка печатала в журнал
|
||||||
// `duplicated key not allowed` уровня error на здоровом старте.
|
// `duplicated key not allowed` уровня error на здоровом старте.
|
||||||
|
|
||||||
|
// Идентичность живых сессий привязана к ПОКОЛЕНИЮ учётных данных.
|
||||||
|
//
|
||||||
|
// Что было. `auth_id` создавался один раз и не менялся никогда, а секрет
|
||||||
|
// менялся отдельно от него. Между этими двумя фактами и жил дефект отзыва:
|
||||||
|
//
|
||||||
|
// до: secret S1 -> authId A
|
||||||
|
// после: secret S2 -> authId A
|
||||||
|
//
|
||||||
|
// Отзыв секрета состоит из двух шагов — записать новый digest и завершить
|
||||||
|
// сессии, установленные по старому. Второй шаг может не удаться, и это
|
||||||
|
// нормально: сходимость обязан обеспечить cron. Но сверять ему было нечем.
|
||||||
|
// Сессия называется в `/online` просто `A`, в базе `A` существует, доступ пиру
|
||||||
|
// открыт, устройств не больше разрешённого — то есть по всем признакам это
|
||||||
|
// действующая сессия нового состояния. Признака «установлена по уже отозванному
|
||||||
|
// секрету» в системе не существовало вовсе.
|
||||||
|
//
|
||||||
|
// Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Ответ авторизации
|
||||||
|
// и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
|
||||||
|
// сначала дожидается `Authenticate`, и только ПОСЛЕ возврата `ok=true`
|
||||||
|
// выставляет `authenticated = true` и вызывает `LogOnlineState(id, true)`
|
||||||
|
// (исходники app/v2.12.2). Значит `/kick`, прошедший, пока backend-auth ещё
|
||||||
|
// выполнялся, этого соединения увидеть не обязан:
|
||||||
|
//
|
||||||
|
// 1. клиент с S1 начинает авторизацию, Hysteria2Auth читает peer и застревает
|
||||||
|
// внутри GET /online;
|
||||||
|
// 2. оператор меняет секрет, UpdatePeer сохраняет S2 и УСПЕШНО зовёт /kick A;
|
||||||
|
// 3. задержанная авторизация возвращает ALLOW и A;
|
||||||
|
// 4. Hysteria регистрирует сессию A — уже после kick'а.
|
||||||
|
//
|
||||||
|
// Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
|
||||||
|
// даёт, поэтому повторным чтением базы перед `return ALLOW` окно не закрыть: оно
|
||||||
|
// сдвинется, но останется. Закрывается это тем, что отозванная генерация
|
||||||
|
// перестаёт быть валидной ИДЕНТИЧНОСТЬЮ:
|
||||||
|
//
|
||||||
|
// до: S1 -> authId A
|
||||||
|
// ротация: S2 -> authId B, /kick A
|
||||||
|
// A в /online -> в базе только B -> orphan -> kick
|
||||||
|
//
|
||||||
|
// То есть используется уже существующий механизм сходимости
|
||||||
|
// (enforcePeerAccess обходит каждый authID из `/online`), а не заводится
|
||||||
|
// отдельный реестр отозванных поколений, очередь повторов и таблица retry.
|
||||||
|
//
|
||||||
|
// Цена решения названа прямо: сессия, пережившая kick, до следующего цикла
|
||||||
|
// учёта считается сессией НЕИЗВЕСТНОГО пира, поэтому её дельта трафика
|
||||||
|
// приписывается некому и попадает в потери цикла (см. saveAccountTraffic).
|
||||||
|
// Это не более 30 секунд трафика одного пира на одну ротацию, и это осознанный
|
||||||
|
// размен: квота здесь — операционная граница доступа, а не биллинг. Колонка
|
||||||
|
// «прежний auth_id» ради этих 30 секунд ввела бы второй идентификатор сессии,
|
||||||
|
// то есть ровно то состояние, из-за которого отзыв и не сходился.
|
||||||
|
|
||||||
|
// peerAuthIDLength — длина генерируемого `auth_id`.
|
||||||
|
//
|
||||||
|
// Значение объявлено здесь, а не тремя литералами `18` по местам создания:
|
||||||
|
// создание через панель, создание импортом и ротация обязаны давать
|
||||||
|
// идентификатор одного вида.
|
||||||
|
const peerAuthIDLength = 18
|
||||||
|
|
||||||
|
// newPeerAuthID создаёт идентичность живых сессий пира.
|
||||||
|
//
|
||||||
|
// Единственный генератор `auth_id` в продукте. Коллизия при 62^18 вариантах
|
||||||
|
// недостижима практически, а если бы случилась — UNIQUE(auth_id) отклонит
|
||||||
|
// запись ДО обращения к `/kick`, и операция вернёт отказ, не изменив состояния.
|
||||||
|
// Повторная попытка внутри генератора для этого не нужна.
|
||||||
|
func newPeerAuthID() (string, error) {
|
||||||
|
return util.RandomString(peerAuthIDLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialGenerationChanged отвечает, действительно ли меняется поколение
|
||||||
|
// учётных данных.
|
||||||
|
//
|
||||||
|
// Отдельная функция, потому что вопрос не тот же самый, что «оператор прислал
|
||||||
|
// секрет». Повторная отправка ТОГО ЖЕ секрета — это запрос на повторный отзыв
|
||||||
|
// (и он по-прежнему рвёт сессию), но нового поколения credentials при этом не
|
||||||
|
// возникает, и менять идентичность сессий незачем: смена `auth_id` без смены
|
||||||
|
// секрета обесценила бы накопленную привязку трафика без единой причины.
|
||||||
|
//
|
||||||
|
// Строка без сохранённого digest считается сменой: чем бы ни было её
|
||||||
|
// содержимое, оно не то, что записывается сейчас.
|
||||||
|
func credentialGenerationChanged(storedDigest *string, newDigest string) bool {
|
||||||
|
return storedDigest == nil || *storedDigest != newDigest
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedSecret приводит присланный секрет к решению «менять или не менять».
|
||||||
|
//
|
||||||
|
// Правило одно на обе задачи — на запись и на разрыв сессии. Раньше их было
|
||||||
|
// два: UpdatePeer проверял `*peerDto.Secret != ""`, а updateRequiresReconcile —
|
||||||
|
// `strings.TrimSpace(...) != ""`. Секрет из одних пробелов, пришедший мимо
|
||||||
|
// нормализации DTO (прямой вызов сервиса, тесты), записывался бы в базу как
|
||||||
|
// новые учётные данные, но сессию бы не рвал — то есть отзыв, о котором
|
||||||
|
// механизм сходимости не знает.
|
||||||
|
func requestedSecret(provided *string) (string, bool) {
|
||||||
|
if provided == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(*provided)
|
||||||
|
return secret, secret != ""
|
||||||
|
}
|
||||||
|
|
||||||
// generatedSecretRandomLength — длина случайной части автогенерируемого
|
// generatedSecretRandomLength — длина случайной части автогенерируемого
|
||||||
// секрета.
|
// секрета.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hy2xs-admin/dao"
|
||||||
|
"hy2xs-admin/model/bo"
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отзыв секрета сходится ДАЖЕ ТОГДА, когда `/kick` был бесполезен.
|
||||||
|
//
|
||||||
|
// Проверяемое свойство одно: после смены секрета сессия, установленная по
|
||||||
|
// старому, не может остаться жить неограниченно долго. Способов оказаться в
|
||||||
|
// таком состоянии два, и они разной природы:
|
||||||
|
//
|
||||||
|
// `/kick` вернул 500 — второй шаг операции не удался;
|
||||||
|
// `/kick` вернул 200, но сессии ещё не — второй шаг удался и всё равно
|
||||||
|
// было: backend-auth по старому секрету не подействовал.
|
||||||
|
// ещё выполнялся
|
||||||
|
//
|
||||||
|
// Второй случай — не отказ и не гонка по памяти. Hysteria дожидается ответа
|
||||||
|
// backend-auth и только ПОСЛЕ `ok=true` выставляет `authenticated = true` и
|
||||||
|
// сообщает Traffic Stats API о новом онлайне, поэтому `/kick`, прошедший в это
|
||||||
|
// окно, соединения просто не видит. Атомарной пары «решение авторизации +
|
||||||
|
// регистрация онлайна» upstream API не даёт.
|
||||||
|
//
|
||||||
|
// Сходимость обеспечивается тем, что новое поколение учётных данных получает
|
||||||
|
// новый `auth_id`: пережившая сессия называется старым значением, которого в
|
||||||
|
// базе больше нет, и очередной цикл учёта видит её как orphan.
|
||||||
|
|
||||||
|
// rotationStats — Traffic Stats API, у которого ОДНО названное обращение к
|
||||||
|
// `/online` удерживается до команды теста, а `/kick` при этом продолжает
|
||||||
|
// обслуживаться.
|
||||||
|
//
|
||||||
|
// Обслуживание `/kick` во время удержания обязательно: воспроизводимое
|
||||||
|
// состояние — это именно «отзыв прошёл целиком, пока авторизация висела», и
|
||||||
|
// стенд, сериализующий оба запроса, его бы не собрал.
|
||||||
|
type rotationStats struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
online map[string]int64
|
||||||
|
kickStatus int
|
||||||
|
kickedKeys [][]string
|
||||||
|
|
||||||
|
holdOnlineCall int
|
||||||
|
onlineCalls int
|
||||||
|
|
||||||
|
held chan struct{}
|
||||||
|
released chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRotationStats(t *testing.T, online map[string]int64, holdOnlineCall int) *rotationStats {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if online == nil {
|
||||||
|
online = map[string]int64{}
|
||||||
|
}
|
||||||
|
stats := &rotationStats{
|
||||||
|
online: online,
|
||||||
|
holdOnlineCall: holdOnlineCall,
|
||||||
|
held: make(chan struct{}),
|
||||||
|
released: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/online":
|
||||||
|
stats.mu.Lock()
|
||||||
|
stats.onlineCalls++
|
||||||
|
call := stats.onlineCalls
|
||||||
|
// Снимок делается на ПРИХОДЕ запроса: к моменту доставки ответа
|
||||||
|
// картина уже другая, и это ровно то, что воспроизводится.
|
||||||
|
snapshot := make(map[string]int64, len(stats.online))
|
||||||
|
for key, value := range stats.online {
|
||||||
|
snapshot[key] = value
|
||||||
|
}
|
||||||
|
stats.mu.Unlock()
|
||||||
|
|
||||||
|
if call == stats.holdOnlineCall {
|
||||||
|
close(stats.held)
|
||||||
|
select {
|
||||||
|
case <-stats.released:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Команды не последовало — отпускаем, чтобы падение было по
|
||||||
|
// существу, а не по таймауту всего прогона.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(snapshot)
|
||||||
|
case "/traffic":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]bo.Hysteria2UserTraffic{})
|
||||||
|
case "/kick":
|
||||||
|
stats.mu.Lock()
|
||||||
|
defer stats.mu.Unlock()
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats.kickedKeys = append(stats.kickedKeys, keys)
|
||||||
|
if stats.kickStatus != 0 {
|
||||||
|
w.WriteHeader(stats.kickStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
pointHysteriaConfigAt(t, server.URL)
|
||||||
|
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||||
|
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) setOnline(online map[string]int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.online = online
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) setKickStatus(status int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.kickStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) forgetKicks() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.kickedKeys = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) kicked() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
out := []string{}
|
||||||
|
for _, keys := range s.kickedKeys {
|
||||||
|
out = append(out, keys...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) awaitHeld(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-s.held:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("удерживаемое обращение к /online так и не пришло")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rotationStats) release() { close(s.released) }
|
||||||
|
|
||||||
|
// authIDOfPeer возвращает текущую идентичность сессий пира.
|
||||||
|
func authIDOfPeer(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
peer, ok := snapshotPeers(t)[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("пир %q не найден", name)
|
||||||
|
}
|
||||||
|
return strVal(peer.AuthId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretDigestOfPeer(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
peer, ok := snapshotPeers(t)[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("пир %q не найден", name)
|
||||||
|
}
|
||||||
|
return strVal(peer.SecretDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ротация через форму панели ----------------------------------------------
|
||||||
|
|
||||||
|
// Смена секрета меняет идентичность живых сессий, а рвётся при этом СТАРАЯ.
|
||||||
|
func TestUpdatePeerSecretRotationRotatesAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, nil, 0)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||||||
|
t.Fatalf("смена секрета отказала: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := authIDOfPeer(t, "alpha1")
|
||||||
|
if after == "alpha-auth-id" {
|
||||||
|
t.Fatal("новое поколение учётных данных сохранило прежнюю идентичность сессий")
|
||||||
|
}
|
||||||
|
if after == "" {
|
||||||
|
t.Fatal("идентичность сессий стёрта вместо ротации")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
|
||||||
|
// сессию. Разрыв по новому не завершил бы ничего.
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||||
|
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сессия, установленная по уже отозванному секрету ПОСЛЕ успешного `/kick`,
|
||||||
|
// завершается очередным циклом учёта.
|
||||||
|
//
|
||||||
|
// Это главный сценарий: ни одна операция здесь не отказала. Отзыв прошёл
|
||||||
|
// целиком, а соединение всё равно оказалось живым — потому что Hysteria
|
||||||
|
// регистрирует его только после возврата backend-auth.
|
||||||
|
func TestSecretRotationReconcilesInFlightOldCredential(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
// Удерживается ПЕРВОЕ обращение к `/online` — то самое, внутри которого
|
||||||
|
// зависает авторизация по старому секрету.
|
||||||
|
stub := startRotationStats(t, map[string]int64{}, 1)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
// 1. Клиент со старым секретом начинает авторизацию и застревает в /online.
|
||||||
|
type authOutcome struct {
|
||||||
|
authID string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
inflight := make(chan authOutcome, 1)
|
||||||
|
go func() {
|
||||||
|
_, authID, err := Hysteria2Auth("alpha1-secret")
|
||||||
|
inflight <- authOutcome{authID: authID, err: err}
|
||||||
|
}()
|
||||||
|
stub.awaitHeld(t)
|
||||||
|
|
||||||
|
// 2. Оператор меняет секрет. Запись проходит, `/kick` отвечает 200 —
|
||||||
|
// операция успешна целиком.
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||||||
|
t.Fatalf("смена секрета отказала: %v", err)
|
||||||
|
}
|
||||||
|
rotated := authIDOfPeer(t, "alpha1")
|
||||||
|
if rotated == "alpha-auth-id" {
|
||||||
|
t.Fatal("идентичность сессий не сменилась вместе с секретом")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Задержанная авторизация возвращается уже ПОСЛЕ разрыва, и Hysteria
|
||||||
|
// регистрирует сессию под старым идентификатором.
|
||||||
|
stub.release()
|
||||||
|
outcome := <-inflight
|
||||||
|
if outcome.err != nil {
|
||||||
|
t.Fatalf("подготовка сценария: авторизация по старому секрету не дошла до ответа: %v", outcome.err)
|
||||||
|
}
|
||||||
|
if outcome.authID != "alpha-auth-id" {
|
||||||
|
t.Fatalf("подготовка сценария: авторизация вернула %q, ожидался старый идентификатор", outcome.authID)
|
||||||
|
}
|
||||||
|
stub.setOnline(map[string]int64{"alpha-auth-id": 1})
|
||||||
|
stub.forgetKicks()
|
||||||
|
|
||||||
|
// 4. Сходимость: сессия названа значением, которого в базе больше нет.
|
||||||
|
CronHandleAccount()
|
||||||
|
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||||
|
t.Fatalf("сессия по отозванному секрету пережила цикл учёта: в /kick ушло %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тот же исход, когда `/kick` при ротации не удался вовсе.
|
||||||
|
func TestSecretRotationRecoversAfterKickFailure(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, map[string]int64{"alpha-auth-id": 1}, 0)
|
||||||
|
stub.setKickStatus(http.StatusInternalServerError)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}))
|
||||||
|
|
||||||
|
// Неудача разрыва не откатывает ни новый секрет, ни новую идентичность:
|
||||||
|
// безопасная половина операции достигнута.
|
||||||
|
if authIDOfPeer(t, "alpha1") == "alpha-auth-id" {
|
||||||
|
t.Fatal("идентичность сессий откачена после неудачного разрыва")
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.setKickStatus(0)
|
||||||
|
stub.forgetKicks()
|
||||||
|
|
||||||
|
CronHandleAccount()
|
||||||
|
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||||
|
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повтор ТОГО ЖЕ секрета — это повторная попытка отзыва, а не новое поколение
|
||||||
|
// учётных данных.
|
||||||
|
//
|
||||||
|
// Разрыв обязан выполниться снова (иначе после неудавшегося `/kick` оператору
|
||||||
|
// пришлось бы придумывать пиру ещё один секрет), а идентичность сессий обязана
|
||||||
|
// остаться прежней: менять её без смены credentials незачем — это обесценило бы
|
||||||
|
// привязку трафика на ровном месте.
|
||||||
|
func TestUpdatePeerSameSecretDoesNotRotateAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, nil, 0)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("alpha1-secret")}); err != nil {
|
||||||
|
t.Fatalf("повторная отправка секрета отказала: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||||||
|
t.Fatalf("идентичность сессий сменилась без смены учётных данных: %q", got)
|
||||||
|
}
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||||
|
t.Fatalf("повторный отзыв не дошёл до /kick: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секрет из одних пробелов означает «не менять» на ОБОИХ путях сразу.
|
||||||
|
//
|
||||||
|
// Раньше правило было записано дважды: запись проверяла `!= ""`, а решение о
|
||||||
|
// разрыве — `TrimSpace(...) != ""`. Такой секрет записывался бы в базу как
|
||||||
|
// новые учётные данные, не разрывая сессий, — то есть отзыв, о котором механизм
|
||||||
|
// сходимости не знает.
|
||||||
|
func TestUpdatePeerBlankSecretChangesNothing(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, nil, 0)
|
||||||
|
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||||
|
|
||||||
|
before := secretDigestOfPeer(t, "alpha1")
|
||||||
|
|
||||||
|
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr(" ")}); err != nil {
|
||||||
|
t.Fatalf("правка отказала: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := secretDigestOfPeer(t, "alpha1"); got != before {
|
||||||
|
t.Fatal("секрет из одних пробелов записан как новые учётные данные")
|
||||||
|
}
|
||||||
|
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||||||
|
t.Fatalf("идентичность сессий сменилась без смены секрета: %q", got)
|
||||||
|
}
|
||||||
|
if got := stub.kicked(); len(got) != 0 {
|
||||||
|
t.Fatalf("сессия разорвана без единого изменения: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ротация импортом ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Импорт — вторая дверь к смене учётных данных, и правило поколения у неё то же.
|
||||||
|
//
|
||||||
|
// Случай, проходивший мимо: файл несёт ПРЕЖНИЙ `auth_id` и НОВЫЙ секрет.
|
||||||
|
// Идентичность сессий оставалась той же, и сессия по отозванному секрету
|
||||||
|
// выглядела законной.
|
||||||
|
func TestImportSecretRotationRotatesAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, map[string]int64{"keeper-auth": 1}, 0)
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
|
||||||
|
item := importItem("keeper", "keeper-auth")
|
||||||
|
item.Secret = "brand-new-secret"
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||||
|
t.Fatalf("импорт отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated := authIDOfPeer(t, "keeper")
|
||||||
|
if rotated == "keeper-auth" {
|
||||||
|
t.Fatalf("импорт нового секрета сохранил прежнюю идентичность сессий")
|
||||||
|
}
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||||
|
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// И сходимость: сессия, пережившая разрыв, становится orphan.
|
||||||
|
stub.forgetKicks()
|
||||||
|
CronHandleAccount()
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||||
|
t.Fatalf("сессия по отозванному импортом секрету пережила цикл учёта: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Совпадение по ИМЕНИ, файл без `auth_id`: идентичность всё равно обязана
|
||||||
|
// смениться вместе с секретом.
|
||||||
|
func TestImportSecretRotationRotatesAuthIDWhenMatchedByName(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
stub := startRotationStats(t, nil, 0)
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
|
||||||
|
item := importItem("keeper", "")
|
||||||
|
item.Secret = "brand-new-secret"
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||||
|
t.Fatalf("импорт отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := authIDOfPeer(t, "keeper"); got == "keeper-auth" {
|
||||||
|
t.Fatal("импорт нового секрета по совпадению имени сохранил прежнюю идентичность сессий")
|
||||||
|
}
|
||||||
|
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||||||
|
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный импорт ТОГО ЖЕ файла идентичность не трогает: поколение учётных
|
||||||
|
// данных не изменилось.
|
||||||
|
//
|
||||||
|
// Без этого перенос пиров превращал бы каждую повторную попытку в смену
|
||||||
|
// идентификаторов всех пиров партии.
|
||||||
|
func TestImportSameSecretKeepsAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startRotationStats(t, nil, 0)
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
|
||||||
|
item := importItem("keeper", "keeper-auth")
|
||||||
|
item.Secret = "keeper-secret" // ровно тот, что уже записан seedPeer
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||||
|
t.Fatalf("импорт отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||||||
|
t.Fatalf("повторный импорт того же секрета сменил идентичность сессий: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл, несущий СВОЙ новый `auth_id`, ротации не требует: идентичность уже
|
||||||
|
// сменилась им самим, и старая сессия становится orphan без нашего участия.
|
||||||
|
func TestImportKeepsExplicitNewAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startRotationStats(t, nil, 0)
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
|
||||||
|
item := importItem("keeper", "new-auth-id")
|
||||||
|
item.Secret = "brand-new-secret"
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||||||
|
t.Fatalf("импорт отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := authIDOfPeer(t, "keeper"); got != "new-auth-id" {
|
||||||
|
t.Fatalf("значение auth_id из файла подменено ротацией: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Импорт без секрета учётных данных не меняет, поэтому и идентичность остаётся.
|
||||||
|
func TestImportWithoutSecretKeepsAuthID(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
startRotationStats(t, nil, 0)
|
||||||
|
seedPeer(t, "keeper", "keeper-auth")
|
||||||
|
|
||||||
|
if err := UpsertPeerExport([]bo.PeerExport{importItem("keeper", "keeper-auth")}); err != nil {
|
||||||
|
t.Fatalf("импорт отказал: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||||||
|
t.Fatalf("импорт без секрета сменил идентичность сессий: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Правило поколения --------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCredentialGenerationChanged(t *testing.T) {
|
||||||
|
stored := "digest-1"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
stored *string
|
||||||
|
next string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"другой digest", &stored, "digest-2", true},
|
||||||
|
{"тот же digest", &stored, "digest-1", false},
|
||||||
|
{"строка без digest", nil, "digest-1", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := credentialGenerationChanged(tc.stored, tc.next); got != tc.want {
|
||||||
|
t.Fatalf("credentialGenerationChanged = %v, ожидалось %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestedSecretTreatsBlankAsAbsent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provided *string
|
||||||
|
want string
|
||||||
|
requested bool
|
||||||
|
}{
|
||||||
|
{"поле отсутствует", nil, "", false},
|
||||||
|
{"пустая строка", strPtr(""), "", false},
|
||||||
|
{"одни пробелы", strPtr(" "), "", false},
|
||||||
|
{"значение с краевыми пробелами", strPtr(" secret "), "secret", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
secret, requested := requestedSecret(tc.provided)
|
||||||
|
if requested != tc.requested || secret != tc.want {
|
||||||
|
t.Fatalf("requestedSecret = (%q, %v), ожидалось (%q, %v)",
|
||||||
|
secret, requested, tc.want, tc.requested)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Идентификатор, выданный ротацией, неотличим от выданного при создании: одна
|
||||||
|
// длина, один алфавит, один генератор.
|
||||||
|
func TestNewPeerAuthIDShape(t *testing.T) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
authID, err := newPeerAuthID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("генерация идентичности отказала: %v", err)
|
||||||
|
}
|
||||||
|
if len(authID) != peerAuthIDLength {
|
||||||
|
t.Fatalf("длина %d, ожидалось %d", len(authID), peerAuthIDLength)
|
||||||
|
}
|
||||||
|
if !peerAuthIDPattern.MatchString(authID) {
|
||||||
|
t.Fatalf("идентификатор %q не проходит собственную проверку продукта", authID)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[authID]; duplicate {
|
||||||
|
t.Fatalf("генератор повторил значение %q", authID)
|
||||||
|
}
|
||||||
|
seen[authID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecProbe отличает «команда ответила ненулевым кодом» от «команду не удалось
|
||||||
|
// запустить».
|
||||||
|
//
|
||||||
|
// Проверка нужна потому, что на этом различии стоит диагностика службы:
|
||||||
|
// `systemctl is-active` отвечает словом состояния в stdout И кодом возврата 3,
|
||||||
|
// а Exec выбрасывает вывод при любом ненулевом коде. Пока другого примитива не
|
||||||
|
// было, «служба неактивна» и «спросить не получилось» приходили в панель одним
|
||||||
|
// и тем же значением.
|
||||||
|
//
|
||||||
|
// Продуктовая среда — Linux, поэтому проверка требует рабочего bash. Там, где
|
||||||
|
// его нет (машина разработчика под Windows), она пропускается: подменять здесь
|
||||||
|
// запуск процесса нечем — именно поведение запуска и проверяется.
|
||||||
|
func requireShell(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if _, err := Exec("true"); err != nil {
|
||||||
|
t.Skip("bash недоступен в этой среде: проверка запуска процесса пропущена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecProbeReturnsOutputWithNonZeroExitCode(t *testing.T) {
|
||||||
|
requireShell(t)
|
||||||
|
|
||||||
|
output, code, err := ExecProbe("echo inactive; exit 3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ненулевой код возврата признан отказом запуска: %v", err)
|
||||||
|
}
|
||||||
|
if code != 3 {
|
||||||
|
t.Fatalf("код возврата %d, ожидался 3", code)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(output) != "inactive" {
|
||||||
|
t.Fatalf("вывод потерян: %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecProbeReturnsZeroExitCode(t *testing.T) {
|
||||||
|
requireShell(t)
|
||||||
|
|
||||||
|
output, code, err := ExecProbe("echo active")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("успешная команда признана отказом: %v", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("код возврата %d, ожидался 0", code)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(output) != "active" {
|
||||||
|
t.Fatalf("вывод потерян: %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствующий интерпретатор — это уже отказ запуска, а не ответ команды.
|
||||||
|
func TestExecProbeReportsLaunchFailure(t *testing.T) {
|
||||||
|
requireShell(t)
|
||||||
|
|
||||||
|
_, _, err := ExecProbe("hy2xs-no-such-command-ever")
|
||||||
|
// bash отвечает кодом 127: команда не найдена — это ОТВЕТ оболочки, а не
|
||||||
|
// отказ запуска, поэтому ошибки здесь быть не должно.
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ответ оболочки 127 признан отказом запуска: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,42 @@ func Exec(cmd string) (string, error) {
|
|||||||
return string(output), nil
|
return string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecProbe запускает команду, для которой НЕНУЛЕВОЙ КОД ВОЗВРАТА — это ответ,
|
||||||
|
// а не отказ.
|
||||||
|
//
|
||||||
|
// Зачем это отдельно от Exec. Exec выбрасывает вывод, как только код возврата
|
||||||
|
// не нулевой, и схлопывает два разных события в одну ошибку:
|
||||||
|
//
|
||||||
|
// systemctl is-active -> "inactive", код 3 — служба неактивна;
|
||||||
|
// systemctl запустить не удалось — состояние неизвестно.
|
||||||
|
//
|
||||||
|
// Для команд-действий (`systemctl restart`) такое поведение верно: там любой
|
||||||
|
// ненулевой код означает неудачу. Но `is-active` — это ВОПРОС, и ответ на него
|
||||||
|
// приезжает именно в stdout вместе с кодом 3. Пока в продукте не было способа
|
||||||
|
// его прочитать, дашборд не мог отличить «Hysteria остановлена» от «спросить не
|
||||||
|
// получилось» и показывал оба состояния как «остановлена».
|
||||||
|
//
|
||||||
|
// Возвращаемая ошибка означает ровно одно: процесс не удалось запустить или
|
||||||
|
// дождаться. Ненулевой код возврата ошибкой здесь не является и приезжает
|
||||||
|
// вызывающему числом.
|
||||||
|
func ExecProbe(cmd string) (string, int, error) {
|
||||||
|
command := exec.Command("bash", "-c", cmd)
|
||||||
|
command.Env = os.Environ()
|
||||||
|
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return string(output), 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return string(output), exitErr.ExitCode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Errorf("probe command failed cmd: %s err: %v", cmd, err)
|
||||||
|
return "", -1, fmt.Errorf("probe command failed cmd: %s", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
func Systemctl(action string, unit string) error {
|
func Systemctl(action string, unit string) error {
|
||||||
_, err := Exec(fmt.Sprintf("systemctl %s %s", action, unit))
|
_, err := Exec(fmt.Sprintf("systemctl %s %s", action, unit))
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
func CompareVersion(version1, version2 string) int {
|
|
||||||
v1 := strings.Split(version1, ".")
|
|
||||||
v2 := strings.Split(version2, ".")
|
|
||||||
|
|
||||||
// Compare major version numbers
|
|
||||||
if v1[0] > v2[0] {
|
|
||||||
return 1
|
|
||||||
} else if v1[0] < v2[0] {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the major version numbers are the same, compare the minor version numbers
|
|
||||||
if len(v1) > 1 && len(v2) > 1 {
|
|
||||||
if v1[1] > v2[1] {
|
|
||||||
return 1
|
|
||||||
} else if v1[1] < v2[1] {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the major and minor versions are the same, compare the revision numbers
|
|
||||||
if len(v1) > 2 && len(v2) > 2 {
|
|
||||||
if v1[2] > v2[2] {
|
|
||||||
return 1
|
|
||||||
} else if v1[2] < v2[2] {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version number is exactly the same
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
+1
-1
@@ -78,7 +78,7 @@
|
|||||||
### Панель
|
### Панель
|
||||||
|
|
||||||
- [admin/04-admin-panel.md](admin/04-admin-panel.md) — HY2XS admin
|
- [admin/04-admin-panel.md](admin/04-admin-panel.md) — HY2XS admin
|
||||||
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, атрибуция
|
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, отсутствие выдуманного состояния, атрибуция
|
||||||
|
|
||||||
### Эксплуатация
|
### Эксплуатация
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Разбор кода перед сборкой `1.0.0-rc3`
|
# Разбор кода перед сборкой `1.0.0-rc3`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Findings base: 6d1686b8 — дерево, на котором найдены дефекты
|
||||||
|
Fixes verified in: 8dcb50a — дерево, на котором проверены исправления
|
||||||
|
```
|
||||||
|
|
||||||
|
Две базы названы отдельно намеренно: сам разбор шёл по первой, а выводы
|
||||||
|
исправлений проверялись по второй, и без этой пары читателю приходилось
|
||||||
|
гадать, к какому состоянию относится каждое утверждение отчёта.
|
||||||
|
|
||||||
Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная
|
Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная
|
||||||
сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор
|
сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор
|
||||||
проводился по состоянию **после** предыдущего прохода
|
проводился по состоянию **после** предыдущего прохода
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# Разбор кода перед сборкой `1.0.0-rc4`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Findings base: 8dcb50a — дерево, на котором найдены дефекты
|
||||||
|
Fixes verified in: рабочее дерево этого прохода
|
||||||
|
```
|
||||||
|
|
||||||
|
Источник — разбор дерева после [прохода
|
||||||
|
rc3](2026-09-02-v1.0.0-rc3-preflight-findings.md) и сверка Hysteria-интеграции с
|
||||||
|
исходниками тега `app/v2.12.2`, а не только с документацией: часть выводов
|
||||||
|
зависит от того, что именно делает upstream-код, а не от того, как это описано.
|
||||||
|
|
||||||
|
Тема прохода — **граница между компонентами**. Предыдущие два прохода привели в
|
||||||
|
порядок внутреннюю логику отзыва доступа: единое правило доступа, правильный
|
||||||
|
порядок «запись → разрыв», повторяемость операций и сходимость через цикл учёта.
|
||||||
|
Этот проход занимается местами, где HY2XS соприкасается с Hysteria и с
|
||||||
|
оператором: идентичностью сессий, адресом control plane, форматом журнала и тем,
|
||||||
|
что панель показывает как факт.
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
| ID | Дефект | Приоритет | Статус |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ROT-01 | Ротация секрета сохраняла идентичность сессий, и отзыв не сходился | P1 | закрыт |
|
||||||
|
| ADM-HY2-01 | Адрес Traffic Stats API имел два несовместимых контракта | P1 | закрыт |
|
||||||
|
| ADM-HY2-02 | JSON-журнал Hysteria 2.12.2 не разбирался ни одной строкой | P2 | закрыт |
|
||||||
|
| ADM-HY2-03 | «Служба остановлена», «неизвестно» и доступность API были одним значением | P2 | закрыт |
|
||||||
|
| ADM-HY2-04 | В production работала upstream-проверка обновлений | P2-low | закрыт |
|
||||||
|
| ADM-HY2-05 | Страница конфигурации показывала дефолты UI вместо файла | P2 | закрыт |
|
||||||
|
| ADM-HY2-06 | Санитайзер выгрузки не следовал по YAML-якорям | P2 | закрыт |
|
||||||
|
| ADM-HY2-07 | Читающий экран отдавал в браузер больше секретов, чем выгрузка | P3 | закрыт |
|
||||||
|
| ADM-HY2-08 | Мёртвые остатки прежней архитектуры | P4 | закрыт |
|
||||||
|
| GATE-03 | Гейт освобождения admission-замка проверял форму, а не замок | P3 | закрыт |
|
||||||
|
| DOC-03 | Отчёт rc3 не называл, к какому дереву относятся выводы | P3 | закрыт |
|
||||||
|
|
||||||
|
ROT-01 и ADM-HY2-01…08 пришли внешним разбором; уточнения ниже — при проверке
|
||||||
|
его выводов по коду и исходникам upstream. Три из них меняли предложенное
|
||||||
|
решение, а не только формулировку, и отмечены как **уточнение**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ROT-01 — ротация секрета сохраняла идентичность сессий
|
||||||
|
|
||||||
|
**Наблюдалось рассуждением и воспроизведено тестом.** `go test -race` здесь
|
||||||
|
зелёный и был зелёным: гонка логическая, работа с памятью в ней не участвует.
|
||||||
|
|
||||||
|
Отзыв секрета состоит из двух шагов — записать новый `secret_digest` и завершить
|
||||||
|
сессии, установленные по старому. Второй шаг умеет не удаться, и это заложено в
|
||||||
|
архитектуру: сходимость обязан обеспечить цикл учёта. Но сверять ему было нечем.
|
||||||
|
|
||||||
|
```text
|
||||||
|
до: secret S1 -> authId A
|
||||||
|
после: secret S2 -> authId A
|
||||||
|
```
|
||||||
|
|
||||||
|
Сессия в `/online` называется просто `A`. Пир `A` в базе существует, доступ ему
|
||||||
|
открыт, устройств не больше разрешённого — по всем признакам это действующая
|
||||||
|
сессия нового состояния. Признака «установлена по уже отозванному секрету» в
|
||||||
|
системе не существовало.
|
||||||
|
|
||||||
|
**Уточнение к внешнему разбору.** Дефект не ограничивался формой панели. Импорт
|
||||||
|
— вторая дверь к смене учётных данных, и через неё проходил тот же случай:
|
||||||
|
запись найдена по имени либо несёт прежний `auth_id`, а секрет в файле новый.
|
||||||
|
`applyPeerImportEntry` писал `auth_id` только тогда, когда он задан в файле,
|
||||||
|
поэтому идентичность оставалась прежней.
|
||||||
|
|
||||||
|
**Путь без единой неудачи.** Помимо неудавшегося `/kick` существует сценарий, в
|
||||||
|
котором все операции успешны. Hysteria дожидается ответа backend-auth и только
|
||||||
|
после `ok = true` помечает соединение аутентифицированным и сообщает о нём
|
||||||
|
Traffic Stats API (`app/v2.12.2`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. клиент с S1 начинает авторизацию, Hysteria2Auth ждёт ответа GET /online
|
||||||
|
2. оператор меняет секрет: запись прошла, /kick вернул 200
|
||||||
|
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
|
||||||
|
4. Hysteria регистрирует сессию — уже после kick'а
|
||||||
|
```
|
||||||
|
|
||||||
|
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт,
|
||||||
|
поэтому повторным чтением базы перед ответом окно не закрыть: оно сдвинется, но
|
||||||
|
останется.
|
||||||
|
|
||||||
|
**Как закрыто.** Поколение учётных данных и идентичность сессий связаны:
|
||||||
|
|
||||||
|
```text
|
||||||
|
peer.id — постоянная идентичность записи
|
||||||
|
secret — учётные данные
|
||||||
|
auth_id — идентичность поколения живых сессий
|
||||||
|
```
|
||||||
|
|
||||||
|
Новый секрет получает новый `auth_id`; `/kick` идёт по старому. Пережившая
|
||||||
|
сессия называется значением, которого в базе больше нет, и цикл учёта видит её
|
||||||
|
как orphan — механизмом, который уже существует. Ротация происходит **тогда и
|
||||||
|
только тогда**, когда меняется `secret_digest`: повторная отправка того же
|
||||||
|
секрета остаётся повторной попыткой отзыва, но нового поколения не создаёт.
|
||||||
|
|
||||||
|
Ни отдельной таблицы отозванных поколений, ни очереди повторов, ни
|
||||||
|
распределённых блокировок не заведено.
|
||||||
|
|
||||||
|
**Цена названа прямо.** До следующего цикла учёта такая сессия считается сессией
|
||||||
|
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
|
||||||
|
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию.
|
||||||
|
Колонка «прежний `auth_id`» ради этих секунд ввела бы второй идентификатор
|
||||||
|
сессии — ровно то состояние, из-за которого отзыв и не сходился.
|
||||||
|
|
||||||
|
**Чем закреплено.** `apps/service/peer_secret_rotation_test.go`: удержание
|
||||||
|
in-flight авторизации внутри `/online` с успешным `/kick` посередине,
|
||||||
|
`/kick` → 500, повтор того же секрета, секрет из одних пробелов, четыре
|
||||||
|
сценария импорта. Плюс гейт приёмки на форму записи в обеих дверях.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-01 — два контракта одного адреса
|
||||||
|
|
||||||
|
`normalizeIpv4Host` принимал любой корректный IPv4, шаблон честно рендерил
|
||||||
|
`trafficStats.listen: <адрес>:36712`, а `assertHysteriaConfigMatchesProfile`
|
||||||
|
сверял установленный конфиг с тем же значением. Все гейты проходили.
|
||||||
|
|
||||||
|
Вторая половина продукта имеет другой контракт: `GetHysteria2ApiPort` берёт из
|
||||||
|
`trafficStats.listen` **только порт**, а `proxy.NewHysteria2Api` всегда строит
|
||||||
|
`http://127.0.0.1:<port>`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=192.168.1.10
|
||||||
|
|
||||||
|
Hysteria слушает 192.168.1.10:36712
|
||||||
|
админка идёт 127.0.0.1:36712
|
||||||
|
↓
|
||||||
|
/online недоступен → лимит устройств fail-closed → отказ авторизации ВСЕМ пирам
|
||||||
|
учёт трафика и принудительное отключение не работают
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть валидная с точки зрения всех проверок конфигурация выключала продукт.
|
||||||
|
|
||||||
|
**Как закрыто.** Адрес зафиксирован: `validateRuntimeConfig` принимает только
|
||||||
|
`127.0.0.1`. Traffic Stats API — внутренний control plane одного процесса на
|
||||||
|
одной машине, сценария с другим адресом у него нет.
|
||||||
|
|
||||||
|
**Уточнение к внешнему разбору.** Одной половины мало. Файл может разойтись с
|
||||||
|
оркестратором правкой руками, поэтому вторая половина фикса — в админке:
|
||||||
|
`parseTrafficStatsPort` больше не отбрасывает хост молча, а называет
|
||||||
|
расхождение. Wildcard и loopback принимаются (обмен через них состоится), любой
|
||||||
|
другой адрес — отказ с указанием на `reconfigure`. Молчаливая подстановка
|
||||||
|
loopback вместо прочитанного значения и есть тот самый второй контракт.
|
||||||
|
|
||||||
|
**Чем закреплено.** `orchestrator/test/env.test.ts` (`0.0.0.0`, `127.0.0.2`,
|
||||||
|
LAN, публичный адрес), `apps/service/config_traffic_stats_test.go`, гейты
|
||||||
|
приёмки на обе половины и на `package/config/hy2xs.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой
|
||||||
|
|
||||||
|
Юнит запускает Hysteria с `HYSTERIA_LOG_FORMAT=json`. Разбор складывал запись
|
||||||
|
прямым `json.Unmarshal` в `vo.LogHysteria2Vo`, у которого `Time string`.
|
||||||
|
|
||||||
|
**Уточнение к внешнему разбору.** Предложенная замена поля на `int64` не
|
||||||
|
работает. JSON-логгер `app/v2.12.2` объявлен так:
|
||||||
|
|
||||||
|
```go
|
||||||
|
TimeKey: "time", LevelKey: "level", MessageKey: "msg",
|
||||||
|
EncodeTime: zapcore.EpochMillisTimeEncoder
|
||||||
|
```
|
||||||
|
|
||||||
|
а `EpochMillisTimeEncoder` печатает `float64` — наносекунды, делённые на
|
||||||
|
миллисекунду, то есть **дробное** число вида `1788321234567.1235`. На `int64`
|
||||||
|
разбор падал бы так же, как на `string`.
|
||||||
|
|
||||||
|
Каждая строка уходила в fallback, и панель показывала сырой JSON: структурный
|
||||||
|
журнал был включён, а структурой никто не пользовался.
|
||||||
|
|
||||||
|
**Как закрыто.** Разбор идёт через `map[string]any`: известные ключи заполняют
|
||||||
|
колонки, остальные (`addr`, `id`, `error`, `listen`, `tx`, …) дописываются к
|
||||||
|
сообщению как `msg [key=value …]` в алфавитном порядке и проходят тот же
|
||||||
|
санитайз. `time` принимается числом и строкой; при отсутствии берётся
|
||||||
|
`__REALTIME_TIMESTAMP` journald.
|
||||||
|
|
||||||
|
**Найдено сверх разбора.** `journalctl -o json` отдаёт `MESSAGE` **массивом
|
||||||
|
байт**, если сообщение не является корректным UTF-8. Прежний `Message string`
|
||||||
|
ронял разбор всей строки, и она молча выпадала из журнала — то есть именно те
|
||||||
|
записи, ради которых журнал чаще всего и открывают.
|
||||||
|
|
||||||
|
**Чем закреплено.** `apps/service/journal_test.go` — записи собираются тем же
|
||||||
|
способом, каким их пишет zap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-03 — «остановлена», «неизвестно» и доступность API были одним значением
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hysteria остановлена
|
||||||
|
Traffic Stats API доступен
|
||||||
|
онлайн: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Три утверждения об одной системе, первые два несовместимы, и все три получены из
|
||||||
|
одного ответа `systemctl`: общий `Hysteria2Online` при неактивной службе отдавал
|
||||||
|
пустую карту **без ошибки**, поэтому сборщик метрик выставлял
|
||||||
|
`apiReachable = true`, ни разу не обратившись к API, а список пиров показывал
|
||||||
|
всех офлайн.
|
||||||
|
|
||||||
|
**Уточнение к внешнему разбору.** Развязать флаги было недостаточно: состояние
|
||||||
|
службы физически нечем было прочитать. `util.Exec` выбрасывает вывод команды,
|
||||||
|
как только код возврата не нулевой, а `systemctl is-active` отвечает словом
|
||||||
|
состояния в stdout **вместе** с кодом 3.
|
||||||
|
|
||||||
|
**Как закрыто.** Добавлен `util.ExecProbe`, для которого ненулевой код — ответ,
|
||||||
|
а не отказ. Состояние службы стало трёхзначным (`active` / `inactive` /
|
||||||
|
`unknown`), `Hysteria2Online` спрашивает Traffic Stats API напрямую и возвращает
|
||||||
|
ошибку, а решает, как её показать, вызывающий: дашборд — отдельными фактами,
|
||||||
|
список пиров — признаком `onlineState: unavailable` и «онлайн неизвестен» вместо
|
||||||
|
«офлайн». Незнакомое слово в ответе systemd означает `unknown`, а не
|
||||||
|
«остановлена».
|
||||||
|
|
||||||
|
**Чем закреплено.** `apps/service/hysteria2_state_test.go` (матрица 2×2),
|
||||||
|
разбор ответа `is-active`, `PagePeer` в обоих состояниях,
|
||||||
|
`apps/util/exec_probe_test.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-04 — upstream-проверка обновлений в production
|
||||||
|
|
||||||
|
`app/v2.12.2` по умолчанию проверяет обновления после старта. В сборочном и e2e
|
||||||
|
окружении HY2XS она отключена, а в `hysteria-server.service` — нет.
|
||||||
|
|
||||||
|
Security-дефекта здесь нет: Hysteria бинарник не заменяет. Дефект
|
||||||
|
архитектурный — у версии обязан быть один владелец
|
||||||
|
(`versions.env` → сборка → пакет → оркестратор), а production не имеет права
|
||||||
|
отличаться от тестового окружения.
|
||||||
|
|
||||||
|
**Как закрыто.** `Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1` в юните + гейт
|
||||||
|
приёмки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-05 — экран показывал дефолты UI вместо файла
|
||||||
|
|
||||||
|
Панель накладывала ответ сервера на полный объект значений по умолчанию, поэтому
|
||||||
|
отвечала не на тот вопрос:
|
||||||
|
|
||||||
|
| в файле | показывалось |
|
||||||
|
| --- | --- |
|
||||||
|
| секции `trafficStats` нет | `listen: :9999` |
|
||||||
|
| `speedTest: false` | вкладка спрятана как «не задано» |
|
||||||
|
| `ignoreClientBandwidth: true` без `bandwidth` | не показано вовсе |
|
||||||
|
| `masquerade.string.statusCode` (200..599) | переключатель |
|
||||||
|
| ни `tls`, ни `acme` | дефолты ACME |
|
||||||
|
|
||||||
|
Экран, существующий ради диагностики расхождений, эти расхождения скрывал.
|
||||||
|
|
||||||
|
**Как закрыто — сокращением, а не развитием.** Ответ описывает
|
||||||
|
production-профиль: значения так, как они записаны (`null` = «не задано»), и
|
||||||
|
отдельный список секций вне профиля, считаемый по сырому YAML — секция, которую
|
||||||
|
типизированная модель не понимает, обязана быть замечена, а не потеряна. Списки
|
||||||
|
секций в Go и в оркестраторе сверяются гейтом приёмки.
|
||||||
|
|
||||||
|
Удалены три редактора, которые ничего не сохраняли (outbounds, список значений,
|
||||||
|
словарь), вместе с их компонентами и view-моделью на `DeepRequired`: пока
|
||||||
|
«универсальный редактор Hysteria» существует в дереве, он отрастает заново.
|
||||||
|
Полный документ по-прежнему доступен санитизированной выгрузкой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-06 — санитайзер не следовал по YAML-якорям
|
||||||
|
|
||||||
|
`redactNode` и `redactSubtree` разбирали документ, последовательность,
|
||||||
|
отображение и скаляр, но не `yaml.AliasNode`.
|
||||||
|
|
||||||
|
**Уточнение к внешнему разбору.** Утечек две, а не одна:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shared: &credential VERY_SECRET_VALUE
|
||||||
|
|
||||||
|
obfs:
|
||||||
|
salamander:
|
||||||
|
password: *credential
|
||||||
|
```
|
||||||
|
|
||||||
|
Значение под `password` — ссылка, и `redactSubtree` на ней был no-op. Само
|
||||||
|
объявление якоря стоит под ключом `shared`, секретоподобным не выглядящим, — и
|
||||||
|
его не трогал никто. Секрет уезжал в выгрузку дважды.
|
||||||
|
|
||||||
|
**Как закрыто.** Обход идёт по цели ссылки: редакция цели закрывает оба
|
||||||
|
вхождения сразу. Защита от циклов обязательна — `yaml.v3` на ссылке, указывающей
|
||||||
|
на предка, строит действительно циклический граф узлов (проверено), и без неё
|
||||||
|
обход не завершился бы.
|
||||||
|
|
||||||
|
**Чем закреплено.** Четыре регрессии в `hysteria2_export_test.go`: скаляр за
|
||||||
|
якорем, URL с учётными данными, составной узел, рекурсивная ссылка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-07 — читающий экран был щедрее выгрузки
|
||||||
|
|
||||||
|
`auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль обфускации,
|
||||||
|
токены ACME DNS, учётные данные outbound-прокси и `masquerade.proxy.url` — нет.
|
||||||
|
Скачиваемая выгрузка того же конфига их вырезает.
|
||||||
|
|
||||||
|
Привилегий это не повышало — маршрут под admin JWT, — но read-only экрану эти
|
||||||
|
значения не нужны. Закрыто вместе с ADM-HY2-05: вместо значения показывается
|
||||||
|
диагностический факт («задан» / «не задан», имена параметров без значений,
|
||||||
|
адрес auth-URL с вырезанным токеном). Проверяется сериализацией ответа целиком,
|
||||||
|
а не перечислением полей: новое поле без `json:"-"` иначе не заметил бы никто.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADM-HY2-08 — мёртвые остатки
|
||||||
|
|
||||||
|
- `util.CompareVersion` — лексикографическое сравнение версий без единого
|
||||||
|
потребителя (`2.10` < `2.9`); удалён вместе с файлом;
|
||||||
|
- `service.ReleaseHysteria2` — `return nil`, вызывавшийся при завершении
|
||||||
|
сервиса; остаток модели, в которой панель считала Hysteria своим подпроцессом;
|
||||||
|
- `PeerClientConfigVo.QrCode` — второй канал доставки QR, который панель рисует
|
||||||
|
сама из ссылки;
|
||||||
|
- компонент `UnitSelect` и три функции `utils/byte.ts` — без потребителей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GATE-03 — гейт проверял форму, а не замок
|
||||||
|
|
||||||
|
Освобождение admission-замка проверялось регулярным выражением
|
||||||
|
`/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь отложенный вызов».
|
||||||
|
Такой гейт пережил бы
|
||||||
|
|
||||||
|
```go
|
||||||
|
unlockAdmission := lockPeerAdmission(...)
|
||||||
|
defer someOtherCleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
— замок, который не отпускается никогда. Теперь имя переменной берётся из самого
|
||||||
|
присваивания, поэтому проверяется освобождение **именно этого** замка, а
|
||||||
|
переименование переменной гейт не ломает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что проверено независимо
|
||||||
|
|
||||||
|
Исходники `app/v2.12.2` — по трём вопросам, от которых зависели решения:
|
||||||
|
порядок `Authenticate` → `authenticated = true` → `LogOnlineState`; точный
|
||||||
|
`EncoderConfig` JSON-логгера (включая тип, который даёт `EpochMillisTimeEncoder`);
|
||||||
|
переменная `HYSTERIA_DISABLE_UPDATE_CHECK`.
|
||||||
|
|
||||||
|
Поведение `yaml.v3` на рекурсивных якорях проверено экспериментом, а не
|
||||||
|
предположением: библиотека строит циклический граф узлов и умеет его же
|
||||||
|
сериализовать обратно.
|
||||||
|
|
||||||
|
## Что остаётся релизным гейтом
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -race ./service/... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
на Debian-билдере. Локально `-race` недоступен (`CGO_ENABLED=0`, компилятора C
|
||||||
|
нет), и это ограничение среды, а не результат. Обе гонки, найденные в этом и
|
||||||
|
предыдущем проходах, — логические: детектор гонок на них молчит принципиально,
|
||||||
|
поэтому закрыты они детерминированными тестами с удержанием ответа `/online`.
|
||||||
|
|
||||||
|
## Ручные проверки на живом сервере
|
||||||
|
|
||||||
|
1. сменить секрет пира при активном подключении; убедиться, что клиент со старым
|
||||||
|
секретом теряет доступ не позднее 30 секунд, а `authId` в списке изменился;
|
||||||
|
2. то же при недоступном Traffic Stats API на момент сохранения (частичный
|
||||||
|
результат) — сходимость обязана произойти после его восстановления;
|
||||||
|
3. подменить `trafficStats.listen` на LAN-адрес: админка обязана назвать
|
||||||
|
расхождение, а страница конфигурации — пометить адрес;
|
||||||
|
4. остановить `hysteria-server`: дашборд показывает «остановлена» и «API
|
||||||
|
недоступен», список пиров — «онлайн неизвестен»;
|
||||||
|
5. сломать доступ к `systemctl` при живой Hysteria: дашборд показывает
|
||||||
|
«состояние неизвестно» и **доступный** API;
|
||||||
|
6. открыть страницу журнала Hysteria: записи разобраны, контекст виден,
|
||||||
|
секретов нет;
|
||||||
|
7. добавить в конфиг секцию вне профиля (например `resolver`) и открыть страницу
|
||||||
|
конфигурации: секция обязана попасть в «расхождение конфигурации».
|
||||||
@@ -42,3 +42,4 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
|
||||||
| 2026-09-02 | коммит `6d1686b8`, повторная сверка с Hysteria 2 | [ADMIT-04/05, RECON-01/02, GATE-02, DOC-02, VER-01](2026-09-02-v1.0.0-rc3-preflight-findings.md) |
|
| 2026-09-02 | коммит `6d1686b8`, повторная сверка с Hysteria 2 | [ADMIT-04/05, RECON-01/02, GATE-02, DOC-02, VER-01](2026-09-02-v1.0.0-rc3-preflight-findings.md) |
|
||||||
|
| 2026-09-02 | коммит `8dcb50a`, сверка с исходниками `app/v2.12.2` | [ROT-01, ADM-HY2-01…08, GATE-03, DOC-03](2026-09-02-v1.0.0-rc4-preflight-findings.md) |
|
||||||
|
|||||||
+228
-58
@@ -360,8 +360,8 @@ AES-GCM. Значение без этого префикса — не «форм
|
|||||||
|
|
||||||
| Слой | Назначение | Поведение при неизвестных полях |
|
| Слой | Назначение | Поведение при неизвестных полях |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
|
| Типизированная модель | значения профиля для панели и генерация клиентских ссылок | неизвестные поля не отображаются |
|
||||||
| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
|
| Сырой YAML | экспорт, а также перечень секций верхнего уровня | неизвестные поля **сохраняются** |
|
||||||
|
|
||||||
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
|
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
|
||||||
|
|
||||||
@@ -371,69 +371,114 @@ AES-GCM. Значение без этого префикса — не «форм
|
|||||||
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
|
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
|
||||||
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
|
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
|
||||||
|
|
||||||
### Третий слой: модель отображения
|
### Третий слой: проекция на production-профиль
|
||||||
|
|
||||||
У типизированной модели есть подслой, о котором стоит сказать отдельно, потому
|
Панель показывает **то, что записано в файле**, — и это отдельный слой, а не
|
||||||
что он определяет, как устроены шаблоны страницы Hysteria.
|
типизированная модель целиком.
|
||||||
|
|
||||||
`Hysteria2ServerConfig` описывает то, что **приходит по сети**, и почти все его
|
|
||||||
секции необязательны — ровно так же, как в upstream YAML. Форма же обращается к
|
|
||||||
ним напрямую: `dataForm.tls.cert`, `dataForm.acme.dns.config`,
|
|
||||||
`dataForm.resolver.https.sni`.
|
|
||||||
|
|
||||||
Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
|
|
||||||
Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` — и он прав: обращение
|
|
||||||
через возможно отсутствующий объект падает в рантайме. Спасало то, что форма
|
|
||||||
строится merge'ем поверх полного объекта значений по умолчанию, то есть
|
|
||||||
инвариант «секция есть всегда» существовал, но держался на порядке присваиваний
|
|
||||||
внутри компонента и нигде не был выражен типом.
|
|
||||||
|
|
||||||
Закрыто одним преобразованием на границе, а не 141 оператором `?.` и не
|
|
||||||
`as any`:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ответ API (Hysteria2ServerConfig, секции необязательны)
|
/etc/hysteria/config.yaml
|
||||||
↓
|
↓
|
||||||
normalizeHysteriaViewModel()
|
типизированная модель (значения) + сырой YAML (ключи верхнего уровня)
|
||||||
↓
|
↓
|
||||||
Hysteria2ServerConfigView — все секции обязательны
|
BuildHysteria2Profile()
|
||||||
↓
|
↓
|
||||||
шаблон
|
Hysteria2ProfileVo: секции профиля + список расхождений
|
||||||
|
↓
|
||||||
|
страница (только чтение)
|
||||||
```
|
```
|
||||||
|
|
||||||
`Hysteria2ServerConfigView` выводится из `Hysteria2ServerConfig` типом, а не
|
**Что было.** Ответ отдавал внутреннюю модель серверного конфига целиком, а
|
||||||
пишется вторым списком полей. Поэтому новая секция в схеме ломает компиляцию на
|
frontend накладывал его на полный объект значений по умолчанию
|
||||||
объекте значений по умолчанию — то есть поле upstream нельзя молча не
|
(`DeepRequired` + merge). В результате экран отвечал не на тот вопрос:
|
||||||
отобразить.
|
|
||||||
|
|
||||||
Побочное следствие: `v-if` в шаблоне перестали проверять присутствие секции и
|
```text
|
||||||
проверяют только то, что действительно определяет выбор ветки. Например для
|
вопрос, который решает оператор:
|
||||||
обфускации это `dataForm.obfs.type === 'gecko'` вместо
|
что реально написано в /etc/hysteria/config.yaml?
|
||||||
`dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko` — вторая половина
|
|
||||||
дублировала первую и существовала только из-за необязательности типа.
|
|
||||||
|
|
||||||
Этот слой не участвует в экспорте: выгрузка идёт от исходного YAML и сохраняет
|
вопрос, на который отвечал экран:
|
||||||
неизвестные поля, поэтому их потеря в модели отображения безвредна.
|
как выглядел бы конфиг, если недостающие куски заполнить дефолтами UI?
|
||||||
|
```
|
||||||
|
|
||||||
|
Разница не косметическая:
|
||||||
|
|
||||||
|
| в файле | показывалось | чем это плохо |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| секции `trafficStats` нет | `listen: :9999` | скрыта причина отказа всего контура доступа |
|
||||||
|
| `speedTest: false` | вкладка спрятана как «не задано» | явное значение выдано за отсутствие |
|
||||||
|
| `disableUDP: false` | то же | то же |
|
||||||
|
| `ignoreClientBandwidth: true` без блока `bandwidth` | не показано вовсе | опция влияет на сервер и невидима |
|
||||||
|
| `masquerade.string.statusCode` (200..599) | переключатель | тип не соответствует upstream |
|
||||||
|
| ни `tls`, ни `acme` | дефолты ACME | выдуманная конфигурация выпуска сертификата |
|
||||||
|
|
||||||
|
То есть экран, существующий ради диагностики расхождений, эти расхождения
|
||||||
|
скрывал.
|
||||||
|
|
||||||
|
**Что показывается теперь.** Секции production-профиля — те, которыми
|
||||||
|
действительно управляет оркестратор:
|
||||||
|
|
||||||
|
```text
|
||||||
|
listen · auth · tls|acme · obfs · bandwidth · ignoreClientBandwidth
|
||||||
|
congestion · quic · trafficStats
|
||||||
|
```
|
||||||
|
|
||||||
|
Отсутствие секции остаётся отсутствием: `null` означает «в файле этого нет», и
|
||||||
|
подменять его дефолтом нельзя — `false`, `0` и пустая строка являются законными
|
||||||
|
значениями и должны быть от него отличимы.
|
||||||
|
|
||||||
|
Всё остальное попадает в **расхождение конфигурации** — список секций верхнего
|
||||||
|
уровня вне профиля (`masquerade`, `resolver`, `sniff`, `acl`, `outbounds`,
|
||||||
|
`mimic`, `realm`, а также любая секция, о которой HY2XS ещё не знает). Он
|
||||||
|
считается по сырому YAML, а не по типизированной модели: секция, которую модель
|
||||||
|
не понимает, обязана быть замечена именно как расхождение, а не потеряна при
|
||||||
|
разборе. Список секций профиля в Go и whitelist оркестратора сверяются гейтом
|
||||||
|
приёмки — два источника истины разъехались бы молча.
|
||||||
|
|
||||||
|
**Почему не универсальный редактор Hysteria.** Продуктом является один профиль:
|
||||||
|
конфиг генерирует оркестратор и сам же проверяет соответствие установленного
|
||||||
|
файла профилю (`assertHysteriaConfigMatchesProfile`). Панель конфиг не пишет —
|
||||||
|
маршрутов записи в API нет. Достраивать её до редактора всех возможностей
|
||||||
|
upstream значит поддерживать вторую, никем не применяемую модель продукта. Для
|
||||||
|
полного документа есть санитизированная выгрузка, которая сохраняет и
|
||||||
|
неизвестные поля.
|
||||||
|
|
||||||
|
### Читающий экран не щедрее выгрузки
|
||||||
|
|
||||||
|
Прежний ответ вёз в браузер секреты. `auth` и `trafficStats.secret` были закрыты
|
||||||
|
`json:"-"`, а пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные
|
||||||
|
данные outbound-прокси и `masquerade.proxy.url` — нет. Скачиваемый экспорт того
|
||||||
|
же конфига их вырезает; привилегий это не повышало (маршрут под admin JWT), но
|
||||||
|
read-only экрану эти значения не нужны вовсе.
|
||||||
|
|
||||||
|
Теперь вместо значения показывается диагностически достаточный факт:
|
||||||
|
|
||||||
|
| поле | что видно оператору |
|
||||||
|
| --- | --- |
|
||||||
|
| `obfs.*.password` | «задан» / «не задан»; сам пароль выдаётся в клиентской ссылке пира |
|
||||||
|
| `trafficStats.secret` | «задан» / «не задан» |
|
||||||
|
| `acme.dns.config` | имена параметров без значений |
|
||||||
|
| `auth.http.url` | адрес с вырезанным `access_token` (тем же санитайзером, что и выгрузка) |
|
||||||
|
|
||||||
### Страница Hysteria — только чтение, и теперь это верно на всех уровнях
|
### Страница Hysteria — только чтение, и теперь это верно на всех уровнях
|
||||||
|
|
||||||
Страница отрисована с `:disabled="true"` и прямо сообщает, что конфигом владеет
|
Страница прямо сообщает, что конфигом владеет `hy2xs-orchestrator reconfigure`.
|
||||||
`hy2xs-orchestrator reconfigure`. Маршрутов записи серверного конфига в API нет
|
Маршрутов записи серверного конфига в API нет — они удалены вместе с мёртвым
|
||||||
— они удалены вместе с мёртвым updater/config-write слоем.
|
updater/config-write слоем.
|
||||||
|
|
||||||
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
|
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
|
||||||
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
|
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
|
||||||
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
|
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
|
||||||
передаются в них как `:outbounds=`, `:tags=`, `:map-object=` — без `v-model`,
|
передавались в них без `v-model`, то есть у их событий `update:*` не было ни
|
||||||
то есть у их событий `update:*` нет ни одного слушателя. Оператор мог добавить
|
одного слушателя. Оператор мог добавить outbound, увидеть его в списке и уйти в
|
||||||
outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию
|
уверенности, что изменил конфигурацию сервера; изменения не переживали даже
|
||||||
сервера; изменения не переживали даже переключения вкладки.
|
переключения вкладки. У одного из них цена была ещё и измеримой: редактор списка
|
||||||
|
значений работал на `vuedraggable`, которая тянула в bundle полную сборку Vue с
|
||||||
|
рантайм-компилятором шаблонов — около полумегабайта ради перетаскивания тегов в
|
||||||
|
недоступной для редактирования форме.
|
||||||
|
|
||||||
Все три приведены к отображению. У одного из них цена была ещё и измеримой:
|
Сначала все три были приведены к отображению, а вместе с переходом на проекцию
|
||||||
редактор списка значений работал на `vuedraggable`, которая поставляется
|
профиля удалены целиком вместе со своими компонентами: пока «универсальный
|
||||||
UMD-сборкой, поэтому её `require("vue")` разрешался в полную сборку Vue вместе с
|
редактор» существует в дереве, он отрастает заново.
|
||||||
рантайм-компилятором шаблонов — около полумегабайта в bundle ради
|
|
||||||
перетаскивания тегов в недоступной для редактирования форме.
|
|
||||||
|
|
||||||
### Санитайз экспорта
|
### Санитайз экспорта
|
||||||
|
|
||||||
@@ -620,6 +665,62 @@ backend.
|
|||||||
| **удаление пира** | сессия пира, по запомненному `authId` |
|
| **удаление пира** | сессия пира, по запомненному `authId` |
|
||||||
| **импорт партии** | сессии всех существующих пиров партии, по СТАРЫМ `authId` |
|
| **импорт партии** | сессии всех существующих пиров партии, по СТАРЫМ `authId` |
|
||||||
|
|
||||||
|
#### Смена секрета меняет и идентичность сессий
|
||||||
|
|
||||||
|
**Контракт:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
peer.id — постоянная идентичность записи
|
||||||
|
secret — учётные данные
|
||||||
|
auth_id — идентичность ПОКОЛЕНИЯ живых Hysteria-сессий
|
||||||
|
```
|
||||||
|
|
||||||
|
Новый секрет получает новый `auth_id`; `/kick` при этом идёт по **старому** —
|
||||||
|
именно им Hysteria знает отзываемую сессию. Правило действует на обеих дверях к
|
||||||
|
смене учётных данных: и в форме панели, и в импорте.
|
||||||
|
|
||||||
|
Ротация происходит тогда и только тогда, когда меняется `secret_digest`.
|
||||||
|
Повторная отправка того же секрета — это повторная попытка отзыва (она рвёт
|
||||||
|
сессию снова, как и повторное «Отключить»), но нового поколения credentials не
|
||||||
|
создаёт, поэтому идентичность сессий не трогает.
|
||||||
|
|
||||||
|
**Зачем это нужно.** Отзыв секрета состоит из двух шагов, и второй умеет не
|
||||||
|
удаться — сходимость обязан обеспечить cron. Но пока `auth_id` оставался
|
||||||
|
прежним, сверять было нечем: сессия, установленная по отозванному секрету,
|
||||||
|
называлась тем же значением, пир в базе существовал, доступ был открыт,
|
||||||
|
устройств не больше разрешённого. Признака «установлена по уже недействительным
|
||||||
|
учётным данным» в системе не существовало вовсе.
|
||||||
|
|
||||||
|
Хуже того, у этого состояния есть путь **без единой неудачи**. Ответ авторизации
|
||||||
|
и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
|
||||||
|
сначала дожидается `Authenticate`, и только после `ok = true` помечает
|
||||||
|
соединение аутентифицированным и сообщает о нём Traffic Stats API. Значит:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. клиент со старым секретом начинает авторизацию,
|
||||||
|
Hysteria2Auth читает пира и ждёт ответа GET /online
|
||||||
|
2. оператор меняет секрет: запись прошла, /kick вернул 200
|
||||||
|
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
|
||||||
|
4. Hysteria регистрирует сессию — уже после kick'а
|
||||||
|
```
|
||||||
|
|
||||||
|
Все шаги успешны, а сессия по отозванному секрету жива. Атомарной пары «решение
|
||||||
|
авторизации + регистрация онлайна» upstream API не даёт, поэтому повторным
|
||||||
|
чтением базы перед ответом это окно не закрыть — оно сдвинется, но останется.
|
||||||
|
|
||||||
|
Ротация `auth_id` закрывает оба случая одним уже существующим механизмом:
|
||||||
|
пережившая сессия называется значением, которого в базе больше нет, и очередной
|
||||||
|
цикл учёта видит её как orphan (см. «Сверка живых сессий»). Ни отдельной таблицы
|
||||||
|
отозванных поколений, ни очереди повторов для этого не заводится.
|
||||||
|
|
||||||
|
**Цена названа прямо:** до следующего цикла учёта такая сессия считается сессией
|
||||||
|
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
|
||||||
|
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию —
|
||||||
|
осознанный размен, о котором см. «Учёт трафика — операционная граница, а не
|
||||||
|
биллинг». Колонка «прежний `auth_id`» ради этих секунд ввела бы второй
|
||||||
|
идентификатор сессии, то есть ровно то состояние, из-за которого отзыв и не
|
||||||
|
сходился.
|
||||||
|
|
||||||
Правило асимметрично намеренно: **ограничение применяется немедленно,
|
Правило асимметрично намеренно: **ограничение применяется немедленно,
|
||||||
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
|
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
|
||||||
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
|
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
|
||||||
@@ -706,10 +807,13 @@ disabled = 1 работает: условие смотрит на ЗАПР
|
|||||||
квота / срок работает: cron видит их через peerAccessDenied
|
квота / срок работает: cron видит их через peerAccessDenied
|
||||||
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
|
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
|
||||||
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
|
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
|
||||||
|
смена секрета НЕ работает: в базе уже новый digest, старая сессия
|
||||||
|
неотличима от законной
|
||||||
```
|
```
|
||||||
|
|
||||||
Две нижние строки не имели механизма схождения вовсе, и обе закрывает cron —
|
Три нижние строки не имели механизма схождения вовсе. Первые две закрывает cron
|
||||||
см. «Сверка живых сессий» ниже. Отдельной таблицы retry, очереди отложенных
|
— см. «Сверка живых сессий» ниже; третью — ротация `auth_id`, после которой она
|
||||||
|
сводится к первым двум: старое поколение становится orphan. Отдельной таблицы retry, очереди отложенных
|
||||||
операций и хранимого «списка того, что не удалось разорвать» для этого не
|
операций и хранимого «списка того, что не удалось разорвать» для этого не
|
||||||
нужно: `/online` и есть список живых сессий.
|
нужно: `/online` и есть список живых сессий.
|
||||||
|
|
||||||
@@ -729,11 +833,11 @@ maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва бол
|
|||||||
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
|
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
|
||||||
блокировки не включает отключённого пира.
|
блокировки не включает отключённого пира.
|
||||||
|
|
||||||
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
|
**Состояние службы по systemd в этом пути не участвует.** Оно годится только для
|
||||||
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
|
отображения и не является основанием ни для отказа операции, ни для её пропуска
|
||||||
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
|
— ни здесь, ни в cron. Ответ даёт само обращение к Traffic Stats API. Как
|
||||||
для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само
|
именно читается состояние службы и почему у него три значения, а не два — см.
|
||||||
обращение к Traffic Stats API.
|
«Состояние службы и доступность API — разные факты».
|
||||||
|
|
||||||
### Цикл учёта принадлежит планировщику
|
### Цикл учёта принадлежит планировщику
|
||||||
|
|
||||||
@@ -779,7 +883,9 @@ Cron обходит **каждый `authId`, который Hysteria счита
|
|||||||
для каждого authId из GET /online:
|
для каждого authId из GET /online:
|
||||||
|
|
||||||
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
|
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
|
||||||
строки в базе нет → /kick (пир удалён либо переподписан)
|
строки в базе нет → /kick (пир удалён, переподписан импортом
|
||||||
|
либо это отозванное поколение
|
||||||
|
учётных данных)
|
||||||
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
|
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
|
||||||
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
|
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
|
||||||
устройств > maxDevices → /kick (лимит снижен, сессии остались)
|
устройств > maxDevices → /kick (лимит снижен, сессии остались)
|
||||||
@@ -827,9 +933,52 @@ Hysteria, значит она жива, а её Traffic Stats API слушает
|
|||||||
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
|
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
|
||||||
доступность Traffic Stats API входит в install/doctor smoke.
|
доступность Traffic Stats API входит в install/doctor smoke.
|
||||||
|
|
||||||
Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров
|
### Состояние службы и доступность API — разные факты
|
||||||
показывают пустую картину, когда служба остановлена, — это честный ответ на
|
|
||||||
вопрос «кто сейчас на связи».
|
Путь отображения тоже строгий, и это исправление, а не ужесточение ради
|
||||||
|
симметрии.
|
||||||
|
|
||||||
|
**Что было.** Общий `Hysteria2Online` начинался с ярлыка «служба неактивна по
|
||||||
|
мнению systemd → пустая карта, ошибки нет». Пустая карта БЕЗ ошибки неотличима
|
||||||
|
от «никто не подключён», поэтому сборщик метрик выставлял `apiReachable = true`,
|
||||||
|
ни разу не обратившись к Traffic Stats API, а список пиров показывал всех
|
||||||
|
офлайн. Дашборд умел утверждать одновременно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hysteria остановлена
|
||||||
|
Traffic Stats API доступен
|
||||||
|
онлайн: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
— три утверждения об одной системе, из которых первые два несовместимы, и все
|
||||||
|
три получены из одного ответа `systemctl`.
|
||||||
|
|
||||||
|
**Источник неопределённости.** `util.Exec` выбрасывает вывод команды, как только
|
||||||
|
код возврата не нулевой, а `systemctl is-active` отвечает словом состояния в
|
||||||
|
stdout ВМЕСТЕ с кодом 3. Прочитать это слово было нечем, поэтому «служба
|
||||||
|
неактивна» и «спросить не получилось» приходили в панель одним значением
|
||||||
|
`false`.
|
||||||
|
|
||||||
|
**Как теперь.** Два источника отвечают на два вопроса, и ни один не выводится из
|
||||||
|
другого:
|
||||||
|
|
||||||
|
| источник | значения |
|
||||||
|
| --- | --- |
|
||||||
|
| `systemctl is-active` через `util.ExecProbe` | `active` · `inactive` · `unknown` |
|
||||||
|
| фактическое обращение к Traffic Stats API | доступен · недоступен |
|
||||||
|
|
||||||
|
`unknown` — это не «остановлена». Дашборд показывает для него предупреждение
|
||||||
|
«состояние службы неизвестно», а не критическую плашку «служба остановлена»: у
|
||||||
|
этих двух состояний разные действия оператора, и второе отправляло его
|
||||||
|
перезапускать работающий туннель.
|
||||||
|
|
||||||
|
Список пиров при недоступном API отвечает `onlineState: unavailable` — один
|
||||||
|
признак на страницу, а не nullable-флаг в каждой строке, — и показывает «онлайн
|
||||||
|
неизвестен» вместо «офлайн». Число подключённых устройств в этом состоянии
|
||||||
|
показывается как `?`: ноль был бы утверждением, которого никто не проверял.
|
||||||
|
|
||||||
|
Решения о доступе на этих значениях по-прежнему не строятся: авторизация и cron
|
||||||
|
спрашивают Traffic Stats API напрямую.
|
||||||
|
|
||||||
#### Лимит выдерживает параллельные подключения
|
#### Лимит выдерживает параллельные подключения
|
||||||
|
|
||||||
@@ -1126,3 +1275,24 @@ Compatibility-ветка пережила слой совместимости,
|
|||||||
26. неудавшийся разрыв не оставляет систему в несогласованном состоянии
|
26. неудавшийся разрыв не оставляет систему в несогласованном состоянии
|
||||||
навсегда: сессия удалённого либо переподписанного пира и превышение
|
навсегда: сессия удалённого либо переподписанного пира и превышение
|
||||||
`maxDevices` устраняются очередным циклом учёта
|
`maxDevices` устраняются очередным циклом учёта
|
||||||
|
27. смена секрета меняет `auth_id`, поэтому сессия, установленная по отозванным
|
||||||
|
учётным данным, становится orphan и завершается очередным циклом учёта — в
|
||||||
|
том числе когда `/kick` прошёл успешно, но соединение зарегистрировалось
|
||||||
|
после него
|
||||||
|
28. `auth_id` генерируется ровно одним способом (`newPeerAuthID`), и ротация
|
||||||
|
происходит тогда и только тогда, когда меняется `secret_digest`
|
||||||
|
29. адрес Traffic Stats API — внутренний контракт: оркестратор допускает только
|
||||||
|
`127.0.0.1`, а админка называет расхождение вместо молчаливой подстановки
|
||||||
|
loopback
|
||||||
|
30. состояние службы по systemd имеет три значения, и `unknown` не выдаётся за
|
||||||
|
«остановлена»; доступность Traffic Stats API — независимый факт, полученный
|
||||||
|
фактическим обращением
|
||||||
|
31. отказ Traffic Stats API отображается как «состояние неизвестно», а не как
|
||||||
|
«все пиры офлайн»
|
||||||
|
32. журнал Hysteria разбирается в фактическом формате upstream (числовое `time`)
|
||||||
|
и сохраняет структурный контекст записи
|
||||||
|
33. страница конфигурации показывает записанные значения без синтетических
|
||||||
|
дефолтов, отдельно перечисляет секции вне production-профиля и не отдаёт
|
||||||
|
браузеру секретов
|
||||||
|
34. секреты не покидают сервер и через YAML-якоря: санитайзер выгрузки следует
|
||||||
|
по ссылкам
|
||||||
|
|||||||
@@ -209,7 +209,51 @@ ElMessageBox.confirm(...)` без разбора отказа оставляет
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Атрибуция
|
## 8. Панель не выдумывает состояние
|
||||||
|
|
||||||
|
**Правило.** Отсутствие данных показывается как отсутствие данных.
|
||||||
|
|
||||||
|
Нарушений было три, и все три давали оператору ответ, противоположный истине.
|
||||||
|
|
||||||
|
**Страница конфигурации** строила форму merge'ем ответа сервера поверх полного
|
||||||
|
объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
|
||||||
|
показывалась как `listen: :9999`, а явное `speedTest: false` считалось
|
||||||
|
ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики
|
||||||
|
дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (`null`) от
|
||||||
|
значения, а секции вне production-профиля перечисляются отдельным списком
|
||||||
|
расхождений.
|
||||||
|
|
||||||
|
**Список пиров** получал `onlineUsers, _ := Hysteria2Online()` и при любом сбое
|
||||||
|
control plane показывал всех пиров офлайн. Теперь страница несёт
|
||||||
|
`onlineState: ok | unavailable` — один признак на ответ, а не флаг в каждой
|
||||||
|
строке, — и при `unavailable` показывает «онлайн неизвестен», а число устройств
|
||||||
|
как `?`.
|
||||||
|
|
||||||
|
**Дашборд** выводил доступность Traffic Stats API из ответа `systemctl` и умел
|
||||||
|
утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два
|
||||||
|
независимых факта, а у состояния службы три значения: `active`, `inactive`,
|
||||||
|
`unknown`.
|
||||||
|
|
||||||
|
**Запрещено:**
|
||||||
|
|
||||||
|
* подставлять значение по умолчанию вместо отсутствующего в ответе;
|
||||||
|
* показывать `0`, `false` или «офлайн» там, где данные не получены;
|
||||||
|
* выводить один факт из другого, если их можно спросить по отдельности.
|
||||||
|
|
||||||
|
**Секреты на читающем экране.** Read-only страница не имеет права быть щедрее
|
||||||
|
санитизированной выгрузки того же документа. Вместо значения показывается
|
||||||
|
диагностический факт: «задан» / «не задан» для паролей и секретов, имена
|
||||||
|
параметров без значений для `acme.dns.config`, адрес с вырезанным
|
||||||
|
`access_token` для auth-URL.
|
||||||
|
|
||||||
|
**Редакторы без сохранения запрещены.** Конфигом владеет оркестратор, маршрутов
|
||||||
|
записи в API нет, поэтому поля ввода на странице конфигурации обещают действие,
|
||||||
|
которого не существует. Проверяется контрактным тестом: в шаблоне нет ни
|
||||||
|
`el-input`, ни `el-switch`, ни `el-select`, ни `v-model`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Атрибуция
|
||||||
|
|
||||||
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||||
|
|||||||
@@ -246,3 +246,9 @@ HY2XS обязан **понимать** современную схему Hyster
|
|||||||
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
|
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
|
||||||
11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
|
11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
|
||||||
12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
|
12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
|
||||||
|
13. `trafficStats.listen` слушает loopback: Traffic Stats API — внутренний
|
||||||
|
control plane, и админка обращается к нему только по `127.0.0.1`. Любой
|
||||||
|
другой адрес разводит компоненты по разным адресам и выключает лимит
|
||||||
|
устройств, учёт трафика и принудительное отключение разом
|
||||||
|
14. Hysteria не проверяет обновления сама (`HYSTERIA_DISABLE_UPDATE_CHECK=1`):
|
||||||
|
версией владеет `versions.env` -> сборка -> пакет -> оркестратор
|
||||||
|
|||||||
@@ -347,6 +347,56 @@ ClientAliveCountMax 2
|
|||||||
- auth material
|
- auth material
|
||||||
- что используется совместимый клиентский конфиг
|
- что используется совместимый клиентский конфиг
|
||||||
|
|
||||||
|
### Ни один пир не проходит авторизацию
|
||||||
|
|
||||||
|
Симптом резкий: подключения перестают устанавливаться у всех сразу, в журнале
|
||||||
|
админки — `device limit unavailable`.
|
||||||
|
|
||||||
|
Лимит устройств проверяется **fail-closed**: без ответа `/online` админка не
|
||||||
|
знает, сколько устройств уже на связи, и пускать подключения не имеет права.
|
||||||
|
Значит вопрос ровно один — почему недоступен Traffic Stats API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. что записано в конфиге
|
||||||
|
grep -A2 '^trafficStats:' /etc/hysteria/config.yaml
|
||||||
|
|
||||||
|
# 2. отвечает ли API по этому адресу
|
||||||
|
curl -sS -H "Authorization: <trafficStatsSecret>" http://127.0.0.1:36712/online
|
||||||
|
|
||||||
|
# 3. что говорит сама админка
|
||||||
|
journalctl -u hy2xs-admin -n 100 --no-pager | grep -i 'traffic stats'
|
||||||
|
```
|
||||||
|
|
||||||
|
Частая причина — правка `trafficStats.listen` руками. Админка обращается к
|
||||||
|
Traffic Stats API **только по loopback**, поэтому адрес вроде `192.168.1.10`
|
||||||
|
разводит Hysteria и панель по разным адресам: сама Hysteria работает, туннели
|
||||||
|
существующих клиентов живут, но лимит устройств, учёт трафика и принудительное
|
||||||
|
отключение выключаются разом. С таким конфигом админка отказывает явно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
trafficStats.listen слушает 192.168.1.10, а админка обращается к Traffic Stats
|
||||||
|
API только по loopback. ...Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`
|
||||||
|
```
|
||||||
|
|
||||||
|
Страница конфигурации показывает тот же адрес и помечает его как не-loopback.
|
||||||
|
|
||||||
|
### Дашборд показывает «состояние службы неизвестно»
|
||||||
|
|
||||||
|
Это **не** «Hysteria остановлена». Значение означает, что не удалось получить
|
||||||
|
ответ `systemctl is-active hysteria-server`: сломанный или недоступный systemctl
|
||||||
|
при живой Hysteria выглядит именно так.
|
||||||
|
|
||||||
|
Проверяется отдельно от туннеля:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl is-active hysteria-server # active | inactive | failed | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступность Traffic Stats API на дашборде — независимый факт, полученный
|
||||||
|
фактическим обращением к API. Сочетание «состояние неизвестно» + «API доступен»
|
||||||
|
означает исправно работающий туннель и сломанную диагностику службы; сочетание
|
||||||
|
«служба активна» + «API недоступен» — предыдущий раздел.
|
||||||
|
|
||||||
### Install/reconfigure падает на DNS AAAA
|
### Install/reconfigure падает на DNS AAAA
|
||||||
Проверить значение `HY2XS_DNS_AAAA_POLICY` в `/etc/hy2xs/hy2xs.env`:
|
Проверить значение `HY2XS_DNS_AAAA_POLICY` в `/etc/hy2xs/hy2xs.env`:
|
||||||
- `strict` (default): AAAA приводит к fail в IPv4-only профиле;
|
- `strict` (default): AAAA приводит к fail в IPv4-only профиле;
|
||||||
|
|||||||
@@ -207,6 +207,30 @@ hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --conf
|
|||||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap поля.
|
- `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap поля.
|
||||||
- изменение значений в `/etc/hy2xs/hy2xs.env` после install не выполняет автоматическую ротацию существующих credentials.
|
- изменение значений в `/etc/hy2xs/hy2xs.env` после install не выполняет автоматическую ротацию существующих credentials.
|
||||||
|
|
||||||
|
## 10a. Отзыв учётных данных пира
|
||||||
|
|
||||||
|
Смена секрета в панели — операция отзыва, и она выполняется целиком:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. новый secret_digest и новый auth_id записываются одной операцией
|
||||||
|
2. POST /kick по СТАРОМУ auth_id
|
||||||
|
3. если разрыв не удался либо соединение зарегистрировалось уже после него —
|
||||||
|
старая сессия становится orphan и завершается очередным циклом учёта
|
||||||
|
```
|
||||||
|
|
||||||
|
Что это значит для оператора:
|
||||||
|
|
||||||
|
- гарантия отзыва — **не позднее 30 секунд** (интервал цикла учёта), а не «до
|
||||||
|
переподключения клиента по своей воле»;
|
||||||
|
- частичный результат (`peer_disconnect_failed`) означает лишь то, что первая
|
||||||
|
попытка разрыва не удалась: повторять операцию не требуется, состояние сойдётся
|
||||||
|
само;
|
||||||
|
- идентификатор пира в списке (`authId`) после смены секрета меняется — это
|
||||||
|
идентичность поколения сессий, а не постоянный идентификатор записи;
|
||||||
|
- трафик старой сессии за эти секунды не приписывается пиру и попадает в потери
|
||||||
|
цикла учёта (запись уровня `error` в журнале админки). Для операционной
|
||||||
|
границы доступа это допустимо; биллингом учёт трафика в `1.0.0` не является.
|
||||||
|
|
||||||
## 11. IPv4/IPv6 policy
|
## 11. IPv4/IPv6 policy
|
||||||
|
|
||||||
- HY2XS работает в IPv4-only режиме.
|
- HY2XS работает в IPv4-only режиме.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@
|
|||||||
- `ReadWritePaths=/var/lib/hysteria`
|
- `ReadWritePaths=/var/lib/hysteria`
|
||||||
- `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`
|
- `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`
|
||||||
|
|
||||||
|
Окружение `hysteria-server.service` фиксировано тремя переменными:
|
||||||
|
|
||||||
|
| переменная | значение | зачем |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HYSTERIA_LOG_LEVEL` | `info` | уровень журнала |
|
||||||
|
| `HYSTERIA_LOG_FORMAT` | `json` | структурный журнал; админка разбирает именно его формат — `time` числом epoch millis |
|
||||||
|
| `HYSTERIA_DISABLE_UPDATE_CHECK` | `1` | версией Hysteria владеет `versions.env` -> сборка -> пакет -> оркестратор |
|
||||||
|
|
||||||
|
Проверка обновлений выключена не из соображений безопасности — Hysteria сама
|
||||||
|
бинарник не заменяет, — а потому что у версии обязан быть один владелец. Иначе
|
||||||
|
служба ходит наружу при каждом старте и печатает в журнал советы, которые к этой
|
||||||
|
установке не относятся. В тестовом окружении продукта она и так отключена, и
|
||||||
|
production не имеет права отличаться.
|
||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
- HY2XS admin не должен запускаться как часть unit Hysteria
|
- HY2XS admin не должен запускаться как часть unit Hysteria
|
||||||
- unit-файлы не должны быть склеены
|
- unit-файлы не должны быть склеены
|
||||||
|
|||||||
@@ -637,6 +637,101 @@ wildcard-маршрутом фронтенда или дублирующая р
|
|||||||
- неизвестные поля и файл не с расширением `.json` отклоняются;
|
- неизвестные поля и файл не с расширением `.json` отклоняются;
|
||||||
- корректный одиночный документ доходит до базы и создаёт пира.
|
- корректный одиночный документ доходит до базы и создаёт пира.
|
||||||
|
|
||||||
|
## A10a. Отзыв учётных данных и сходимость сессий (unit)
|
||||||
|
|
||||||
|
`apps/service/peer_secret_rotation_test.go` — контракт «новое поколение
|
||||||
|
credentials получает новую идентичность сессий». Проверяется поведение, а не
|
||||||
|
наличие поля:
|
||||||
|
|
||||||
|
- **сессия, установленная по отозванному секрету ПОСЛЕ успешного `/kick`,
|
||||||
|
завершается очередным циклом учёта.** Сценарий воспроизводится буквально:
|
||||||
|
авторизация по старому секрету удерживается внутри `GET /online`, за это время
|
||||||
|
выполняется полная ротация с `/kick` → 200, затем авторизация отпускается и
|
||||||
|
возвращает старый `authId` — то есть соединение регистрируется уже после
|
||||||
|
разрыва. Ни одна операция при этом не отказала; сходимость даёт то, что старое
|
||||||
|
поколение стало orphan;
|
||||||
|
- то же после `/kick` → 500;
|
||||||
|
- повторная отправка **того же** секрета рвёт сессию (повтор отзыва), но
|
||||||
|
идентичность не меняет: `secret_digest` тот же;
|
||||||
|
- секрет из одних пробелов не меняет ничего — ни digest, ни `auth_id`, ни
|
||||||
|
сессии; прежде правило было записано двумя разными условиями, и такой секрет
|
||||||
|
записывался бы в базу, не разрывая сессий;
|
||||||
|
- импорт: новый секрет при **прежнем** `authId` в файле и при совпадении по
|
||||||
|
**имени** ротирует идентичность; свой новый `authId` из файла не подменяется;
|
||||||
|
повторный импорт того же файла идентичность не трогает;
|
||||||
|
- `newPeerAuthID` выдаёт идентификатор той же формы, что и создание пира, и
|
||||||
|
проходит собственную проверку продукта (`peerAuthIDPattern`).
|
||||||
|
|
||||||
|
`apps/service/cron_test.go` дополнительно доказывает, что старое поколение
|
||||||
|
уходит в `/kick` именно как сессия без строки в базе.
|
||||||
|
|
||||||
|
## A10b. Правдивая диагностика (unit)
|
||||||
|
|
||||||
|
`apps/service/hysteria2_state_test.go` — матрица двух независимых источников:
|
||||||
|
|
||||||
|
| systemd | Traffic Stats API | что обязано быть показано |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `active` | отвечает | служба работает, API доступен, картина подключений реальная |
|
||||||
|
| `inactive` | молчит | оба факта согласованы |
|
||||||
|
| **`unknown`** | **отвечает** | «состояние неизвестно» + API доступен + картина подключений реальная |
|
||||||
|
| `active` | отказывает | служба работает, API недоступен, состояние данных — `error` |
|
||||||
|
|
||||||
|
Третья строка — главная регрессия: прежний путь показывал здесь «служба
|
||||||
|
остановлена» и «API доступен» одновременно, причём второе — не сходив в API.
|
||||||
|
|
||||||
|
Отдельно проверяется разбор ответа `systemctl is-active`: `active`, `inactive`,
|
||||||
|
`failed`, `activating`, `deactivating`, пробелы, многострочный вывод, пустой
|
||||||
|
ответ и **незнакомое слово** — последнее означает `unknown`, а не «остановлена».
|
||||||
|
|
||||||
|
`apps/service/peer_access_test.go` — `PagePeer` отвечает
|
||||||
|
`onlineState: unavailable` и не теряет список пиров, когда Traffic Stats API
|
||||||
|
недоступен; при доступном API признак `ok`, а `online`/`onlineDevices` в строках
|
||||||
|
отражают фактический ответ.
|
||||||
|
|
||||||
|
`apps/util/exec_probe_test.go` — `ExecProbe` отличает ненулевой код возврата
|
||||||
|
(ответ команды) от невозможности запустить процесс. Требует рабочего `bash`,
|
||||||
|
поэтому вне Linux пропускается.
|
||||||
|
|
||||||
|
## A10c. Журнал Hysteria в фактическом формате upstream (unit)
|
||||||
|
|
||||||
|
`apps/service/journal_test.go` — записи собираются так же, как их пишет zap с
|
||||||
|
`EncoderConfig` upstream:
|
||||||
|
|
||||||
|
- числовое `time` (epoch millis, **дробное** — `EpochMillisTimeEncoder` делит
|
||||||
|
наносекунды на миллисекунду) разбирается; прежний разбор падал на каждой такой
|
||||||
|
строке и показывал оператору сырой JSON;
|
||||||
|
- структурный контекст (`addr`, `id`, `error`, `listen`, `tx`, …) сохраняется и
|
||||||
|
дописывается к сообщению в устойчивом (алфавитном) порядке;
|
||||||
|
- числа печатаются без экспоненты, вложенные объекты — компактным JSON в одну
|
||||||
|
строку;
|
||||||
|
- секреты вырезаются и из сообщения, и из контекста, а адрес остаётся читаемым;
|
||||||
|
- не-JSON строка и JSON без `msg` не теряются;
|
||||||
|
- запись без собственных `level`/`time` добирает их из journald;
|
||||||
|
- `MESSAGE`, отданный journald **массивом байт** (сообщение не является
|
||||||
|
корректным UTF-8), больше не выбрасывает всю запись.
|
||||||
|
|
||||||
|
## A10d. Проекция конфига на production-профиль (unit)
|
||||||
|
|
||||||
|
`apps/service/hysteria2_profile_test.go`:
|
||||||
|
|
||||||
|
- канонический конфиг оркестратора читается целиком и расхождений не даёт;
|
||||||
|
- отсутствующая секция остаётся отсутствующей — в частности, `trafficStats` не
|
||||||
|
превращается в выдуманный `:9999`;
|
||||||
|
- явное `false` отличается от «не задано»;
|
||||||
|
- секции вне профиля перечисляются поимённо и по порядку, включая **неизвестные
|
||||||
|
HY2XS** — они считаются по сырому YAML, а не по типизированной модели;
|
||||||
|
- ответ, сериализованный **так, как его получит браузер**, не содержит ни
|
||||||
|
пароля обфускации, ни секрета Traffic Stats API, ни machine token, ни учётных
|
||||||
|
данных outbound; при этом диагностические факты сохранены («пароль задан»,
|
||||||
|
адрес auth-URL, имена параметров ACME DNS);
|
||||||
|
- отсутствующий файл конфига — отказ, а не пустой профиль.
|
||||||
|
|
||||||
|
`apps/service/hysteria2_export_test.go` дополнен якорями YAML: секрет за
|
||||||
|
`&anchor`/`*alias` вырезается и по ссылке, и в самом объявлении; URL с учётными
|
||||||
|
данными за якорем — тоже; ссылка на составной узел редактируется целиком;
|
||||||
|
рекурсивная ссылка (alias на предка) не зацикливает санитайзер и не отказывает —
|
||||||
|
`yaml.v3` строит на ней действительно циклический граф узлов.
|
||||||
|
|
||||||
## A7. Контракт версий (build)
|
## A7. Контракт версий (build)
|
||||||
|
|
||||||
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
|
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
|
||||||
27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
|
27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
|
||||||
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
|
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
|
||||||
|
29. смена секрета пира меняет его `auth_id`: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда `/kick` прошёл успешно, а соединение зарегистрировалось после него
|
||||||
|
30. `trafficStats.listen` слушает `127.0.0.1`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback
|
||||||
|
31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет
|
||||||
|
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
||||||
|
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
||||||
|
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
||||||
|
|
||||||
## C1. Семантический smoke конфига
|
## C1. Семантический smoke конфига
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY`
|
17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY`
|
||||||
18. импорт пиров с невалидной записью — файл не применяется частично
|
18. импорт пиров с невалидной записью — файл не применяется частично
|
||||||
19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer`
|
19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer`
|
||||||
|
20. `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` не равен `127.0.0.1` — install/reconfigure
|
||||||
|
отказывают: `0.0.0.0`, другой адрес loopback, адрес LAN, публичный адрес
|
||||||
|
21. `trafficStats.listen` в уже установленном `/etc/hysteria/config.yaml`
|
||||||
|
указывает на не-loopback адрес — админка отказывает с сообщением, называющим
|
||||||
|
адрес и способ починки, а не молча обращается к `127.0.0.1`
|
||||||
|
|
||||||
## E. Fix20 production matrix (обязательные сценарии)
|
## E. Fix20 production matrix (обязательные сценарии)
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,39 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
|||||||
if (config.hysteriaBindHost !== "0.0.0.0") {
|
if (config.hysteriaBindHost !== "0.0.0.0") {
|
||||||
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
|
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Traffic Stats API — ВНУТРЕННИЙ control plane, и его адрес не является
|
||||||
|
* операторской настройкой.
|
||||||
|
*
|
||||||
|
* Что было. `normalizeIpv4Host` принимал любой корректный IPv4, шаблон
|
||||||
|
* честно рендерил `trafficStats.listen: <любой IP>:36712`, и проверка
|
||||||
|
* профиля сверяла конфиг с тем же значением — то есть валидная с точки
|
||||||
|
* зрения всех гейтов конфигурация ломала продукт.
|
||||||
|
*
|
||||||
|
* Ломала потому, что у второй половины продукта контракт другой: админка
|
||||||
|
* берёт из `trafficStats.listen` только ПОРТ и всегда ходит на loopback
|
||||||
|
* (apps/proxy/hysteria2_api.go). При `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST`
|
||||||
|
* равном, скажем, LAN-адресу, Hysteria слушает один адрес, админка стучится
|
||||||
|
* в другой, и дальше по цепочке: `/online` недоступен -> лимит устройств
|
||||||
|
* fail-closed -> отказ авторизации ВСЕМ пирам, учёт трафика и принудительное
|
||||||
|
* отключение не работают. Всё это — от одной строки в env, прошедшей все
|
||||||
|
* проверки.
|
||||||
|
*
|
||||||
|
* `0.0.0.0` отдельным дефектом не является — админка достучится через
|
||||||
|
* loopback, — но публикует внутренний API на всех интерфейсах, что при
|
||||||
|
* `HY2XS_FIREWALL_MODE=external|off` не прикрыто ничем.
|
||||||
|
*
|
||||||
|
* Поэтому адрес фиксирован. Делать его настраиваемым на стороне админки —
|
||||||
|
* неверное направление: у внутреннего API одного процесса на одной машине
|
||||||
|
* нет сценария, в котором ему нужен другой адрес.
|
||||||
|
*/
|
||||||
|
if (config.hysteriaTrafficStatsHost !== "127.0.0.1") {
|
||||||
|
throw new Error(
|
||||||
|
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST is fixed in the HY2XS production profile: 127.0.0.1. " +
|
||||||
|
"Traffic Stats API is an internal control plane: the admin backend always reaches it over loopback, " +
|
||||||
|
"so any other address leaves Hysteria and the panel talking past each other."
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!config.uiPublicAccess && config.uiBindHost !== "127.0.0.1") {
|
if (!config.uiPublicAccess && config.uiBindHost !== "127.0.0.1") {
|
||||||
throw new Error("HY2XS UI must bind to 127.0.0.1 in production baseline");
|
throw new Error("HY2XS UI must bind to 127.0.0.1 in production baseline");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,3 +177,59 @@ describe("остальные production-инварианты", () => {
|
|||||||
expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
|
expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адрес Traffic Stats API — внутренний контракт двух компонентов, а не
|
||||||
|
* настройка.
|
||||||
|
*
|
||||||
|
* Проверка существует потому, что прежняя валидация была не просто мягкой, а
|
||||||
|
* согласованно неверной: env принимал любой IPv4, шаблон честно его
|
||||||
|
* подставлял, а `assertHysteriaConfigMatchesProfile` сверял конфиг с тем же
|
||||||
|
* значением. Все гейты проходили, а продукт при этом переставал работать —
|
||||||
|
* админка читает из `trafficStats.listen` только порт и всегда обращается на
|
||||||
|
* loopback, поэтому расхождение адресов выключает лимит устройств, учёт
|
||||||
|
* трафика и принудительное отключение разом (fail-closed: пиры не проходят
|
||||||
|
* авторизацию вовсе).
|
||||||
|
*/
|
||||||
|
describe("адрес Traffic Stats API", () => {
|
||||||
|
test("канонический 127.0.0.1 принимается", () => {
|
||||||
|
expect(baselineConfig().hysteriaTrafficStatsHost).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("значение по умолчанию не зависит от наличия ключа", () => {
|
||||||
|
expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: null }).hysteriaTrafficStatsHost).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wildcard достижим по loopback, поэтому связь компонентов он не ломает —
|
||||||
|
// но публикует внутренний control plane на всех интерфейсах, и при
|
||||||
|
// HY2XS_FIREWALL_MODE=external|off его не прикрывает ничто.
|
||||||
|
test("0.0.0.0 отклоняется", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "0.0.0.0" })).toThrow(
|
||||||
|
/TRAFFIC_STATS_HOST is fixed/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("другой адрес loopback отклоняется", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "127.0.0.2" })).toThrow(
|
||||||
|
/TRAFFIC_STATS_HOST is fixed/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("адрес LAN отклоняется", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "192.168.1.10" })).toThrow(
|
||||||
|
/TRAFFIC_STATS_HOST is fixed/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("публичный адрес отклоняется", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "203.0.113.10" })).toThrow(
|
||||||
|
/TRAFFIC_STATS_HOST is fixed/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Порт настройкой остаётся: обе половины продукта берут его из одного и того
|
||||||
|
// же `trafficStats.listen`, поэтому разойтись по нему они не могут.
|
||||||
|
test("порт остаётся настраиваемым", () => {
|
||||||
|
expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_PORT: "34712" }).hysteriaTrafficStatsPort).toBe(34712);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ HY2XS_HYSTERIA_BIND_HOST=0.0.0.0
|
|||||||
HY2XS_HYSTERIA_PORT=443
|
HY2XS_HYSTERIA_PORT=443
|
||||||
# fixed in production profile: must remain http
|
# fixed in production profile: must remain http
|
||||||
HY2XS_HYSTERIA_AUTH_MODE=http
|
HY2XS_HYSTERIA_AUTH_MODE=http
|
||||||
|
# fixed in production profile: Traffic Stats API is an internal control plane.
|
||||||
|
# The admin backend always reaches it over loopback, so any other address makes
|
||||||
|
# Hysteria and the panel talk past each other: the device limit, traffic
|
||||||
|
# accounting and forced disconnect stop working at once.
|
||||||
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
|
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1
|
||||||
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712
|
HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712
|
||||||
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__
|
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=__GENERATE__
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ User=hysteria
|
|||||||
Group=hysteria
|
Group=hysteria
|
||||||
Environment=HYSTERIA_LOG_LEVEL=info
|
Environment=HYSTERIA_LOG_LEVEL=info
|
||||||
Environment=HYSTERIA_LOG_FORMAT=json
|
Environment=HYSTERIA_LOG_FORMAT=json
|
||||||
|
# Версией Hysteria владеет один источник: versions.env -> сборка -> пакет ->
|
||||||
|
# оркестратор. Собственная проверка обновлений upstream (включена по умолчанию,
|
||||||
|
# см. app/v2.12.2 root.go) ничего не обновляет, а лишь ходит наружу при каждом
|
||||||
|
# старте и печатает в журнал советы, которые к этой установке не относятся.
|
||||||
|
# Тестовое окружение продукта её и так отключает — production отличаться не
|
||||||
|
# должен.
|
||||||
|
Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1
|
||||||
ExecStart=/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml
|
ExecStart=/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|||||||
@@ -1663,7 +1663,7 @@ run_access_revocation_acceptance() {
|
|||||||
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
|
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
|
||||||
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
|
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
|
||||||
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
|
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
|
||||||
! code_has apps/service/cron.go -F -- 'Hysteria2IsRunning' \
|
! code_has apps/service/cron.go -E -- 'Hysteria2(IsRunning|ServiceState)\(' \
|
||||||
|| fail "acceptance: the account cron job must not gate on the systemd state"
|
|| fail "acceptance: the account cron job must not gate on the systemd state"
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||||
@@ -1675,7 +1675,7 @@ run_access_revocation_acceptance() {
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((line) => !/^\s*\/\//.test(line))
|
.filter((line) => !/^\s*\/\//.test(line))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (body.includes("Hysteria2IsRunning")) {
|
if (/Hysteria2(IsRunning|ServiceState)\(|hysteriaServiceState\(/.test(body)) {
|
||||||
throw new Error("auth gates on the systemd state");
|
throw new Error("auth gates on the systemd state");
|
||||||
}
|
}
|
||||||
' || fail "acceptance: auth must not gate on the systemd state"
|
' || fail "acceptance: auth must not gate on the systemd state"
|
||||||
@@ -1721,7 +1721,21 @@ run_access_revocation_acceptance() {
|
|||||||
// Замок обязан быть взят ДО чтения статистики: взятый после него он
|
// Замок обязан быть взят ДО чтения статистики: взятый после него он
|
||||||
// защищал бы только резервацию, а переупорядочиваются именно снимки.
|
// защищал бы только резервацию, а переупорядочиваются именно снимки.
|
||||||
if (gate > online) throw new Error("the admission gate is taken after the /online read");
|
if (gate > online) throw new Error("the admission gate is taken after the /online read");
|
||||||
if (!/defer\s+\w+\(\)/.test(body)) throw new Error("the admission gate is never released");
|
// Освобождение проверяется ИМЕННО ЭТОГО замка.
|
||||||
|
//
|
||||||
|
// Здесь стояло `/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь
|
||||||
|
// отложенный вызов». Такой гейт пережил бы
|
||||||
|
//
|
||||||
|
// unlockAdmission := lockPeerAdmission(...)
|
||||||
|
// defer someOtherCleanup()
|
||||||
|
//
|
||||||
|
// — то есть замок, который не отпускается никогда. Имя переменной берётся
|
||||||
|
// из самого присваивания, поэтому её переименование гейт не ломает.
|
||||||
|
const binding = body.match(/(\w+)\s*:=\s*lockPeerAdmission\(/);
|
||||||
|
if (!binding) throw new Error("the admission gate result is not bound to a variable");
|
||||||
|
if (!new RegExp("defer\\s+" + binding[1] + "\\(\\)").test(body)) {
|
||||||
|
throw new Error("the admission gate is never released");
|
||||||
|
}
|
||||||
// Замок именно по authId. Литеральный ключ означал бы один замок на
|
// Замок именно по authId. Литеральный ключ означал бы один замок на
|
||||||
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
|
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
|
||||||
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
|
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
|
||||||
@@ -1947,7 +1961,7 @@ run_access_revocation_acceptance() {
|
|||||||
}
|
}
|
||||||
' || fail "acceptance: the device limit must be fail-closed"
|
' || fail "acceptance: the device limit must be fail-closed"
|
||||||
|
|
||||||
code_has apps/service/hysteria2.go -F -- 'var hysteria2IsRunning' \
|
code_has apps/service/hysteria2.go -F -- 'var hysteriaServiceState' \
|
||||||
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
|
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
|
||||||
|
|
||||||
log_step "Acceptance: the public endpoint error names no transport"
|
log_step "Acceptance: the public endpoint error names no transport"
|
||||||
@@ -1955,6 +1969,222 @@ run_access_revocation_acceptance() {
|
|||||||
# сообщение заведомо ложную семантику.
|
# сообщение заведомо ложную семантику.
|
||||||
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|
||||||
|| fail "acceptance: the public port error must not claim a TCP transport"
|
|| fail "acceptance: the public port error must not claim a TCP transport"
|
||||||
|
|
||||||
|
log_step "Acceptance: rotating a credential rotates the session identity"
|
||||||
|
# Отзыв секрета состоит из двух шагов — записать новый digest и завершить
|
||||||
|
# старые сессии. Второй шаг может не удаться, и это нормально: сходимость
|
||||||
|
# обязан обеспечить cron. Но сверять ему было нечем, пока `auth_id`
|
||||||
|
# оставался прежним: сессия по отозванному секрету выглядела законной по
|
||||||
|
# всем признакам.
|
||||||
|
#
|
||||||
|
# Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Hysteria
|
||||||
|
# регистрирует соединение в Traffic Stats API только ПОСЛЕ возврата
|
||||||
|
# backend-auth (app/v2.12.2), поэтому `/kick`, прошедший в это окно, его не
|
||||||
|
# видит. Атомарной пары «решение авторизации + регистрация онлайна» upstream
|
||||||
|
# не даёт, и повторное чтение базы перед ответом окно не закрывает.
|
||||||
|
code_has apps/service/peer_secret.go -F -- 'func newPeerAuthID' \
|
||||||
|
|| fail "acceptance: the single auth_id generator is missing"
|
||||||
|
code_has apps/service/peer_secret.go -F -- 'func credentialGenerationChanged' \
|
||||||
|
|| fail "acceptance: the credential generation rule is missing"
|
||||||
|
# Генератор ровно один: `util.RandomString(18)` россыпью по местам создания
|
||||||
|
# означал бы, что ротация и создание умеют разойтись.
|
||||||
|
# Область скана — только исходники Go: `apps` целиком втянул бы
|
||||||
|
# node_modules и dist фронтенда.
|
||||||
|
local auth_id_generators
|
||||||
|
auth_id_generators="$(code_mentions_in 'RandomString(18)' \
|
||||||
|
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
|
||||||
|
[ -z "$auth_id_generators" ] \
|
||||||
|
|| fail "acceptance: auth_id is generated outside newPeerAuthID: $auth_id_generators"
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||||
|
const bodyOf = (name) => {
|
||||||
|
const start = source.indexOf(name);
|
||||||
|
if (start < 0) throw new Error(name + " is missing");
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.indexOf("\nfunc ");
|
||||||
|
return (end < 0 ? rest : rest.slice(0, end))
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !/^\s*\/\//.test(line))
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форма записи: новая идентичность попадает в ТУ ЖЕ операцию, что и новый
|
||||||
|
// digest, — иначе между ними появляется состояние «секрет уже сменён,
|
||||||
|
// сессии ещё старые», которое ничем не отличить от законного.
|
||||||
|
for (const fn of ["func UpdatePeer", "func applyPeerImportEntry"]) {
|
||||||
|
const body = bodyOf(fn);
|
||||||
|
if (!body.includes("credentialGenerationChanged(")) {
|
||||||
|
throw new Error(fn + " does not check the credential generation");
|
||||||
|
}
|
||||||
|
if (!/updates\["auth_id"\]\s*=/.test(body)) {
|
||||||
|
throw new Error(fn + " does not rotate the session identity with the secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
|
||||||
|
// сессию. Разрыв по новому не завершил бы ничего.
|
||||||
|
const update = bodyOf("func UpdatePeer");
|
||||||
|
if (!update.includes("reconcileLiveSessions([]string{authIDOf(before)})")) {
|
||||||
|
throw new Error("UpdatePeer no longer disconnects the previous session identity");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: a rotated secret must not keep the old session identity"
|
||||||
|
|
||||||
|
log_step "Acceptance: the Traffic Stats API address is one contract"
|
||||||
|
# Оркестратор принимал любой IPv4, шаблон честно его подставлял, а проверка
|
||||||
|
# профиля сверяла конфиг с тем же значением: все гейты проходили, а продукт
|
||||||
|
# переставал работать — админка читает из `trafficStats.listen` только порт
|
||||||
|
# и всегда идёт на loopback.
|
||||||
|
code_has orchestrator/src/config/env.ts -F -- 'TRAFFIC_STATS_HOST is fixed' \
|
||||||
|
|| fail "acceptance: the orchestrator no longer fixes the Traffic Stats host"
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
|
||||||
|
const start = source.indexOf("export function validateRuntimeConfig");
|
||||||
|
if (start < 0) throw new Error("validateRuntimeConfig is missing");
|
||||||
|
const body = source.slice(start);
|
||||||
|
if (!/config\.hysteriaTrafficStatsHost\s*!==\s*"127\.0\.0\.1"/.test(body)) {
|
||||||
|
throw new Error("the Traffic Stats host is not pinned to loopback");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: HY2XS_HYSTERIA_TRAFFIC_STATS_HOST must be pinned to 127.0.0.1"
|
||||||
|
grep -q '^HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127\.0\.0\.1$' package/config/hy2xs.env \
|
||||||
|
|| fail "acceptance: the packaged env no longer pins the Traffic Stats host"
|
||||||
|
# Вторая половина контракта: админка не подставляет loopback молча вместо
|
||||||
|
# прочитанного адреса, а называет расхождение.
|
||||||
|
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|
||||||
|
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently"
|
||||||
|
|
||||||
|
log_step "Acceptance: the systemd state has three values, not two"
|
||||||
|
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
|
||||||
|
# отвечает словом состояния в stdout ВМЕСТЕ с кодом 3. Пока другого
|
||||||
|
# примитива не было, «служба остановлена» и «спросить не удалось» приходили
|
||||||
|
# в панель одним значением.
|
||||||
|
code_has apps/util/linux.go -F -- 'func ExecProbe' \
|
||||||
|
|| fail "acceptance: the exit-code aware probe is missing"
|
||||||
|
code_has apps/service/hysteria2.go -F -- 'HysteriaServiceUnknown' \
|
||||||
|
|| fail "acceptance: the unknown service state is missing"
|
||||||
|
! code_has apps/service/hysteria2.go -F -- 'is-active --quiet' \
|
||||||
|
|| fail "acceptance: --quiet suppresses the very output the probe needs"
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||||
|
const start = source.indexOf("func Hysteria2Online");
|
||||||
|
if (start < 0) throw new Error("Hysteria2Online 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");
|
||||||
|
// Ярлык «служба неактивна -> пусто, ошибки нет» делал недоступность
|
||||||
|
// Traffic Stats API неотличимой от «никто не подключён»: сборщик метрик
|
||||||
|
// выставлял apiReachable = true, ни разу не сходив в API.
|
||||||
|
if (/hysteria2IsRunning\(\)|HysteriaServiceState\(\)/.test(body)) {
|
||||||
|
throw new Error("the display path still derives the online picture from systemd");
|
||||||
|
}
|
||||||
|
if (/return\s+map\[string\]int64\{\}\s*,\s*nil/.test(body)) {
|
||||||
|
throw new Error("the display path still invents an empty online picture");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: the online picture must come from the Traffic Stats API"
|
||||||
|
code_has apps/model/vo/dashboard.go -E -- 'ServiceState +string' \
|
||||||
|
|| fail "acceptance: the dashboard no longer reports the service state separately"
|
||||||
|
code_has apps/model/vo/peer.go -F -- 'PeerOnlineStateUnavailable' \
|
||||||
|
|| fail "acceptance: the peer list cannot report an unknown online state"
|
||||||
|
! code_has apps/service/peer.go -E -- 'onlineUsers,\s*_\s*:=' \
|
||||||
|
|| fail "acceptance: the peer list swallows the Traffic Stats failure again"
|
||||||
|
|
||||||
|
log_step "Acceptance: the Hysteria journal is parsed in its actual format"
|
||||||
|
# JSON-логгер Hysteria 2.12.2 пишет `time` числом (EpochMillisTimeEncoder,
|
||||||
|
# причём дробным), поэтому разбор в структуру со строковым полем падал на
|
||||||
|
# КАЖДОЙ строке и уходил в fallback: `HYSTERIA_LOG_FORMAT=json` был включён,
|
||||||
|
# а структурой никто не пользовался.
|
||||||
|
code_has apps/service/journal.go -F -- 'func hysteriaLogTime' \
|
||||||
|
|| fail "acceptance: the journal no longer understands the upstream time format"
|
||||||
|
code_has apps/service/journal.go -F -- 'func hysteriaLogContext' \
|
||||||
|
|| fail "acceptance: the structured context of the journal is dropped"
|
||||||
|
! code_has apps/service/journal.go -E -- 'json\.Unmarshal\(\[\]byte\(.*\), &parsed\)' \
|
||||||
|
|| fail "acceptance: the journal record is parsed straight into the display type again"
|
||||||
|
|
||||||
|
log_step "Acceptance: production does not run the upstream update check"
|
||||||
|
# Версией Hysteria владеет versions.env -> сборка -> пакет -> оркестратор.
|
||||||
|
# Параллельная проверка обновлений ничего не обновляет, ходит наружу при
|
||||||
|
# каждом старте и отличает production от тестового окружения, где она уже
|
||||||
|
# выключена.
|
||||||
|
grep -q '^Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1$' package/systemd/hysteria-server.service \
|
||||||
|
|| fail "acceptance: the hysteria unit does not disable the upstream update check"
|
||||||
|
|
||||||
|
log_step "Acceptance: the config screen shows the file, not a rendering of it"
|
||||||
|
# Панель накладывала ответ сервера на полный объект дефолтов, поэтому
|
||||||
|
# отсутствующая секция trafficStats показывалась как `:9999`. Экран,
|
||||||
|
# существующий ради диагностики дрейфа, этот дрейф скрывал.
|
||||||
|
code_has apps/service/hysteria2_profile.go -F -- 'func BuildHysteria2Profile' \
|
||||||
|
|| fail "acceptance: the production-profile projection is missing"
|
||||||
|
code_has apps/controller/config.go -F -- 'service.BuildHysteria2Profile()' \
|
||||||
|
|| fail "acceptance: the config route no longer returns the profile projection"
|
||||||
|
! code_has apps/controller/config.go -F -- 'service.GetHysteria2Config()' \
|
||||||
|
|| fail "acceptance: the config route serializes the internal model to the browser again"
|
||||||
|
# Списки секций профиля в Go и в оркестраторе обязаны совпадать: два
|
||||||
|
# источника истины разъехались бы молча, и панель начала бы называть
|
||||||
|
# расхождением то, что оркестратор считает нормой.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const go = fs.readFileSync("apps/service/hysteria2_profile.go", "utf8");
|
||||||
|
const ts = fs.readFileSync("orchestrator/src/steps/configAssertions.ts", "utf8");
|
||||||
|
|
||||||
|
const goBlock = go.match(/var hysteria2ProfileSections = \[\]string\{([\s\S]*?)\}/);
|
||||||
|
if (!goBlock) throw new Error("the Go profile section list is missing");
|
||||||
|
const goSections = [...goBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
|
||||||
|
|
||||||
|
const tsBlock = ts.match(/const allowed = new Set\(\[([\s\S]*?)\]\)/);
|
||||||
|
if (!tsBlock) throw new Error("the orchestrator profile section list is missing");
|
||||||
|
const tsSections = [...tsBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
// В оркестраторе режимы TLS взаимоисключающие, панель показывает оба.
|
||||||
|
const expected = [...new Set([...tsSections, "acme", "tls"])].sort();
|
||||||
|
|
||||||
|
if (goSections.join(",") !== expected.join(",")) {
|
||||||
|
throw new Error(
|
||||||
|
"profile sections diverged:\n go: " + goSections.join(",") + "\n ts: " + expected.join(",")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
' || fail "acceptance: the profile section lists must not diverge"
|
||||||
|
|
||||||
|
log_step "Acceptance: the read-only config view carries no secrets"
|
||||||
|
# Скачиваемая выгрузка того же конфига секреты вырезает; читающий экран не
|
||||||
|
# имеет права быть щедрее. Пароль обфускации, токены ACME DNS и учётные
|
||||||
|
# данные outbound-прокси уезжали в браузер в открытом виде.
|
||||||
|
# Выравнивание полей структуры делает gofmt, поэтому число пробелов между
|
||||||
|
# именем и типом задавать нельзя.
|
||||||
|
code_has apps/model/vo/hysteria2_profile.go -E -- 'PasswordSet +bool' \
|
||||||
|
|| fail "acceptance: the profile view still carries the obfuscation password"
|
||||||
|
code_has apps/model/vo/hysteria2_profile.go -E -- 'SecretSet +bool' \
|
||||||
|
|| fail "acceptance: the profile view still carries the Traffic Stats secret"
|
||||||
|
! code_has apps/model/vo/hysteria2_profile.go -E -- '(Password|Secret) +\*?string' \
|
||||||
|
|| fail "acceptance: the profile view declares a secret-bearing field"
|
||||||
|
code_has apps/service/hysteria2_profile.go -F -- 'sanitizeURLValue(' \
|
||||||
|
|| fail "acceptance: the auth url reaches the browser with its machine token"
|
||||||
|
|
||||||
|
log_step "Acceptance: the config sanitizer follows YAML aliases"
|
||||||
|
# redactNode и redactSubtree разбирали документ, последовательность,
|
||||||
|
# отображение и скаляр, но не AliasNode: секрет за якорем покидал сервер
|
||||||
|
# дважды — и по ссылке, и в самом объявлении якоря.
|
||||||
|
code_has apps/service/hysteria2_export.go -F -- 'yaml.AliasNode' \
|
||||||
|
|| fail "acceptance: the config sanitizer ignores YAML aliases"
|
||||||
|
code_has apps/service/hysteria2_export.go -F -- 'func newAliasGuard' \
|
||||||
|
|| fail "acceptance: the alias walk has no cycle guard"
|
||||||
|
|
||||||
|
log_step "Acceptance: the removed lifecycle leftovers stay removed"
|
||||||
|
# Мёртвые остатки прежней архитектуры: лексикографическое сравнение версий
|
||||||
|
# без потребителя, пустая заглушка освобождения Hysteria и второй канал
|
||||||
|
# доставки QR-кода, который панель рисует сама.
|
||||||
|
[ ! -f apps/util/string.go ] \
|
||||||
|
|| fail "acceptance: the unused lexicographic CompareVersion is back"
|
||||||
|
local dead
|
||||||
|
for dead in CompareVersion ReleaseHysteria2; do
|
||||||
|
local carriers
|
||||||
|
carriers="$(code_mentions_in "${dead}(" \
|
||||||
|
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
|
||||||
|
[ -z "$carriers" ] \
|
||||||
|
|| fail "acceptance: the removed ${dead} is referenced again: $carriers"
|
||||||
|
done
|
||||||
|
! code_has apps/model/vo/peer.go -F -- 'QrCode' \
|
||||||
|
|| fail "acceptance: the deprecated QR transport is back in the peer response"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Наблюдаемость: журнал админки и страницы, которые его показывают.
|
# Наблюдаемость: журнал админки и страницы, которые его показывают.
|
||||||
|
|||||||
@@ -262,6 +262,20 @@ function codeOf(source: string): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* То же для SFC: у шаблона свои комментарии.
|
||||||
|
*
|
||||||
|
* `codeOf` отбрасывает только комментарии JS/TS, а в `<template>` они
|
||||||
|
* записываются как `<!-- … -->`. Комментарий, объясняющий, почему на этом
|
||||||
|
* месте больше нет выдуманного `:9999`, обязан называть его по имени — и
|
||||||
|
* проверка «такого значения в файле нет» падала ровно на той документации,
|
||||||
|
* которая описывает выполненную ею же работу. Это тот же урок, что уже записан
|
||||||
|
* в `codeOf`, просто повторившийся этажом выше.
|
||||||
|
*/
|
||||||
|
function vueCodeOf(source: string): string {
|
||||||
|
return codeOf(source.replace(/<!--[\s\S]*?-->/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
const LOG_PAGES = [
|
const LOG_PAGES = [
|
||||||
["views", "log", "system", "index.vue"],
|
["views", "log", "system", "index.vue"],
|
||||||
["views", "log", "hysteria", "index.vue"],
|
["views", "log", "hysteria", "index.vue"],
|
||||||
@@ -478,6 +492,120 @@ describe("действия над пиром", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("страница конфигурации Hysteria", () => {
|
||||||
|
const configPage = () => readSource("views", "hysteria", "list", "index.vue");
|
||||||
|
const configTypes = () => readSource("api", "config", "types.ts");
|
||||||
|
|
||||||
|
// Страница показывает ФАЙЛ, а не представление о файле.
|
||||||
|
//
|
||||||
|
// Прежняя версия строила форму merge'ем ответа сервера поверх полного
|
||||||
|
// объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
|
||||||
|
// показывалась как `:9999`, а `speedTest: false` считался ненастроенным.
|
||||||
|
// Диагностический экран скрывал ровно тот дрейф, ради которого его
|
||||||
|
// открывают.
|
||||||
|
test("синтетических значений по умолчанию не осталось", () => {
|
||||||
|
const source = vueCodeOf(configPage());
|
||||||
|
|
||||||
|
expect(source).not.toContain(":9999");
|
||||||
|
expect(source).not.toContain("normalizeHysteriaViewModel");
|
||||||
|
expect(source).not.toContain("defaultHysteria2ServerConfigView");
|
||||||
|
|
||||||
|
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||||||
|
// его легко подключить обратно.
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Конфигом владеет оркестратор, маршрутов записи в API нет. Поля ввода на
|
||||||
|
// такой странице обещают оператору действие, которого не существует.
|
||||||
|
test("страница не изображает редактируемую форму", () => {
|
||||||
|
const source = vueCodeOf(configPage());
|
||||||
|
|
||||||
|
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
|
||||||
|
expect(source).not.toContain(editable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Расхождение — главный ответ этого экрана: секция вне профиля означает, что
|
||||||
|
// файл правил не оркестратор.
|
||||||
|
test("расхождение конфигурации показывается отдельно", () => {
|
||||||
|
const source = configPage();
|
||||||
|
|
||||||
|
expect(source).toContain("profile.drift");
|
||||||
|
expect(source).toContain("hysteria.driftTitle");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отсутствие Traffic Stats API выключает лимит устройств, учёт трафика и
|
||||||
|
// принудительное отключение разом. Раньше на этом месте показывался
|
||||||
|
// выдуманный адрес.
|
||||||
|
test("отсутствие trafficStats названо отказом, а не значением", () => {
|
||||||
|
const source = configPage();
|
||||||
|
|
||||||
|
expect(source).toContain("hysteria.trafficStatsMissing");
|
||||||
|
expect(source).toContain("trafficStatsIsLoopback");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read-only экран не имеет права быть щедрее санитизированной выгрузки того
|
||||||
|
// же конфига. Пароль обфускации, токены ACME DNS и учётные данные
|
||||||
|
// outbound-прокси в ответе больше не приезжают.
|
||||||
|
test("тип ответа не несёт секретов", () => {
|
||||||
|
const types = codeOf(configTypes());
|
||||||
|
|
||||||
|
expect(types).toContain("passwordSet");
|
||||||
|
expect(types).toContain("secretSet");
|
||||||
|
expect(types).toContain("dnsConfigKeys");
|
||||||
|
|
||||||
|
expect(types).not.toMatch(/\bpassword\s*[?]?:/);
|
||||||
|
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||||||
|
// Секции, которых production-профиль не описывает, в модели ответа не
|
||||||
|
// объявляются вовсе: они попадают в `drift` списком имён.
|
||||||
|
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
|
||||||
|
expect(types).not.toContain(`${section}:`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Компоненты генерического редактора удалены вместе со страницей: пока они
|
||||||
|
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||||||
|
test("компоненты генерического редактора удалены", () => {
|
||||||
|
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
||||||
|
expect(fs.existsSync(path.join(FRONTEND_SRC, "components", component))).toBe(false);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("правдивая диагностика", () => {
|
||||||
|
// «Состояние неизвестно» и «служба остановлена» — разные ответы. Пока
|
||||||
|
// состояние было булевым, сломанный systemctl при живой Hysteria показывал
|
||||||
|
// критическую плашку «служба остановлена».
|
||||||
|
test("дашборд показывает три состояния службы", () => {
|
||||||
|
for (const dictionary of [ru, en] as const) {
|
||||||
|
const states = (dictionary as any).dashboard?.serviceState ?? {};
|
||||||
|
expect(typeof states.active).toBe("string");
|
||||||
|
expect(typeof states.inactive).toBe("string");
|
||||||
|
expect(typeof states.unknown).toBe("string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = readSource("views", "dashboard", "index.vue");
|
||||||
|
expect(dashboard).toContain("summary.hysteria.serviceState");
|
||||||
|
// Доступность API — независимый факт, а не следствие состояния службы.
|
||||||
|
expect(dashboard).toContain("summary.hysteria.apiReachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отказ Traffic Stats API больше не изображается строками «офлайн»: это
|
||||||
|
// ответ, уводящий оператора искать проблему у пользователей.
|
||||||
|
test("список пиров отличает «офлайн» от «неизвестно»", () => {
|
||||||
|
const peerList = readSource("views", "peer", "list", "index.vue");
|
||||||
|
|
||||||
|
expect(peerList).toContain("onlineState");
|
||||||
|
expect(peerList).toContain("peer.onlineUnknown");
|
||||||
|
expect(peerList).toContain("peer.onlineUnavailable");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("подсказки формы пира", () => {
|
describe("подсказки формы пира", () => {
|
||||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||||
|
|||||||
Reference in New Issue
Block a user