fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки

Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
This commit is contained in:
2026-08-29 21:37:38 +05:00
parent 672d455467
commit b99be7d514
50 changed files with 3907 additions and 1622 deletions
+191 -1
View File
@@ -12,7 +12,197 @@ Hardening-проход перед релизом `1.0.0`. Основная те
«только чистая установка» настоящим системным инвариантом, а не строчкой в «только чистая установка» настоящим системным инвариантом, а не строчкой в
документации. документации.
### Исправлено Второй проход закрывает то, что осталось: два контракта приёмки, гарантированно
ронявшие сборку на корректном коде; жизненный цикл планировщика; мёртвое
состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст
графа зависимостей.
### Исправлено — сборка не собиралась
- **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.**
`verify_api_namespace_contract` искал возвращение legacy-пространства имён
через `grep -rlF '/hui'` по списку каталогов, в который входили `tools/build`
и тесты. Поиск находил два файла: `apps/router/router_test.go`, который
ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать отсутствие маршрута, и сам
`versions.sh`, где эта строка стоит в тексте проверки. То есть добавление
теста, закрепляющего очистку, ломало сборку, а до резолва Hysteria дело не
доходило вовсе.
Скан теперь идёт только по runtime production sources и по тому, что уезжает
в пакет, с исключением `*_test.go`. Гарантия не ослабла, а переехала на слой,
где она сильнее: отсутствие маршрута доказывает
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера, и
существование этого теста само стало частью контракта.
- **Второй такой же контракт прятался за первым.** Проверка «импорт пиров не
выходит за транзакцию» брала `source.slice(start)` — файл от начала
`applyPeerImportEntry` и до конца, — захватывая объявленные ниже
`ExistPeerName` и `UpdatePeerLastConnectionAt`. Это обычные операции вне
импорта, которым глобальное соединение положено, поэтому проверка падала на
корректном коде. Замечена не была только потому, что сборка до неё не
доходила. Границей тела функции теперь служит следующее объявление верхнего
уровня.
Отсюда общее правило и помощники `code_without_comments` / `code_mentions_in`
в `acceptance.sh`: приёмка проверяет **код**, а не упоминания.
### Исправлено — runtime
- **Смена расписания сброса трафика размножала планировщики.**
`middleware.InitCron()` вызывался из `runServer` и на каждом вызове создавал
новый `cron.New()`, нигде не сохраняя ссылку; `cron.Stop()` не вызывался
нигде. При этом смена `RESET_TRAFFIC_CRON` выполняла `StopServer()`, а точка
входа крутила `for { runServer() }` и поднимала сервис заново.
Каждая правка добавляла **целый дублирующий набор джоб** — учёт трафика, сбор
метрик, уборка статистики, — а старое расписание сброса продолжало работать.
После двух правок на процессе висели три планировщика и три разных расписания
одновременно. Плюс окно, в котором джобы старого планировщика били в уже
закрытое SQLite-соединение: `releaseResource()` отрабатывал раньше, чем
следующий `runServer` успевал открыть базу.
Планировщик теперь принадлежит процессу: фиксированные джобы регистрируются
один раз, расписание сброса переносится на месте по своему `EntryID`,
HTTP-сервер к смене настройки отношения не имеет. Цикл перезапуска в точке
входа удалён — перезапуском упавшего юнита занимается systemd.
- **Невалидное cron-выражение принималось API и молча отключало сброс трафика.**
Поле в панели — `el-select` с `allow-create`, то есть строка произвольная;
backend принимал её как строку до 128 символов, а ошибка `AddFunc` при
следующем старте только логировалась. Оператор получал успех, панель работала,
автоматический сброс исчезал.
Выражение проверяется до записи в базу тем же парсером (`cron.ParseStandard`),
которым его потом разбирает планировщик. Невалидное значение — отказ, база не
меняется. Пустое значение легально и означает «сброс выключен».
- **`updateConfigs` применял партию частично.** Валидация и запись шли в одном
цикле, поэтому партия «разрешённый ключ + запрещённый» применяла первый и
возвращала ошибку на втором. Существовавший тест ставил запрещённый ключ
первым и не смотрел в базу — поймать это он был неспособен по построению.
Теперь: полная проверка партии → одна транзакция (`dao.WithConfigTx`) →
применение к рантайму. Тест переписан на обратный порядок ключей и проверяет
состояние базы на настоящей SQLite.
- **Сервис не завершался штатно.** `SIGTERM` от systemd убивал процесс на
середине: соединения обрывались, SQLite закрывался вместе с процессом, джобы
могли быть остановлены посреди записи. Добавлено штатное завершение —
планировщик глушится и дожидается запущенных джоб, затем закрывается база.
- **Ложные ERROR в журнале на каждой первой загрузке.** Создание секретов шло по
схеме «сначала INSERT, при ошибке UPDATE», а строки ключей уже существовали из
`seedBaseConfig`: три записи `duplicated key not allowed` уровня error на
совершенно здоровом старте. Зеркальная схема «сначала UPDATE, при ошибке
INSERT» в других местах была хуже — она **тихо не делала ничего**, если строки
не было: UPDATE без совпавших строк не ошибка, поэтому ветка INSERT не
выполнялась, а вызывающий получал сгенерированный секрет как сохранённый. Для
`JWT_SECRET` это означало бы подпись токенов ключом, которого нет в базе.
Обе схемы заменены на `dao.UpsertConfigValue`, решающий по `RowsAffected`.
- **Дублирующая реализация генерации ключей шифрования.** В
`service/peer_secret.go` лежали построчные копии `getOrCreateConfigKey` и
`getPeerSecretEncryptionKey` из `dao/sqlite.go`: две функции в двух пакетах,
порождающие один и тот же материал шифрования. Расхождение между ними
означало бы, что секреты пиров шифруются одним ключом, а расшифровываются
другим. Осталась одна реализация в `dao`.
### Безопасность
- **Bootstrap-пароль администратора писался в журнал открытым текстом.** При
отсутствии `HY2XS_ADMIN_INITIAL_PASSWORD` админка генерировала пароль сама и
печатала его двумя `logrus.Warnf` в `/var/log/hy2xs/hy2xs-admin.log` — файл,
который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Такой пароль
к тому же не знал никто, кроме журнала.
Отсутствие переменной теперь **отказ старта** с объяснением причины. То же для
`HY2XS_ADMIN_CON_PASS` при создании пира установщика: его секрет продублирован
в `/etc/hy2xs/bootstrap-admin.secret`, откуда его читает проверка machine-auth,
и придуманный админкой секрет разошёлся бы с файлом.
- **Собственный журнал админки выгружался без санитайза**, хотя чужой (журнал
Hysteria) — с санитайзом. Теперь оба проходят `SanitizeLogText`, и во вкладке
просмотра тоже.
- **`golang-jwt/jwt` v3 в пути аутентификации.** У v3.2.2 есть GO-2025-3553,
у которой **нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый
код достигается из `ParseToken`, то есть с неаутентифицированного запроса.
Выполнен переход на `jwt/v5`.
Заодно закрыт тихий недостаток: `keyfunc` возвращал ключ, не проверяя алгоритм
подписи, — набор допустимых алгоритмов фактически задавал сам токен. Разбор
ограничен `jwt.WithValidMethods`, проверяются `issuer` и обязательное наличие
срока жизни; пустой `JWT_SECRET` считается повреждённым состоянием, а не
ключом нулевой длины.
- **Вход по несолёному SHA-224 больше невозможен.** `VerifyPassword` принимала
такой хеш как «legacy»-формат предыдущего поколения. В v1 он недостижим:
миграции таблицы `account` удалены, установка возможна только на чистый хост,
конфигурация 0.x отклоняется по схеме. Compatibility-ветка пережила слой
совместимости, ради которого существовала, и осталась запасным путём проверки
пароля слабым алгоритмом в обработчике логина.
- **Modulo bias в генераторе секретов.** `util.RandomString` брала остаток байта
от деления на длину алфавита (62): первые восемь символов выпадали примерно на
четверть чаще остальных. Через эту функцию проходят `JWT_SECRET`,
`PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY`, секрет trafficStats API,
секреты и `auth_id` пиров. Добавлена отбраковка (rejection sampling).
- **Пир установщика был защищён только в импорте.** Обычный CRUD панели
позволял переподписать или переименовать `bootstrap-admin-peer`, молча
рассинхронизировав базу с `/etc/hy2xs/bootstrap-admin.secret`. Защита
распространена на все пути записи; удаление и отключение остаются
разрешёнными — это осознанные действия оператора, не создающие расхождения.
- **Латентная паника в разборе токена.** `service.GetToken` доставала токен
через `strings.SplitN(header, " ", 2)[1]` и падала на заголовке без пробела.
Единственный потребитель — резервная ветка `GetAdminInfo`, недостижимая и
проверявшая меньше, чем middleware (ни статус учётной записи, ни версию
токена). Оба удалены: разбор токена у продукта ровно один.
- **`reset-admin` генерировал 6-символьные логин и пароль** — нижняя граница,
которую пропускает `HashPassword`. Увеличено до 12 и 24.
### Изменено
- **Toolchain переведён на поддерживаемые линии.** `GO_VERSION` 1.21.13 → 1.26.7,
`NODE_VERSION` 20.19.0 (EOL) → 24.20.0. Go компилирует `hy2xs-admin`, поэтому
его stdlib целиком попадает в production-бинарь: на прежнем графе
`govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в stdlib.
После перехода и обновления зависимостей — **ноль**.
Bun намеренно оставлен на 1.3.13: оркестратор собирается через
`bun build --compile`, то есть Bun runtime входит в исполняемый файл, и смена
его версии требует отдельного прохода по всей матрице проверок.
- **Добавлен обязательный шаг проверки зависимостей** (`tools/build/lib/security.sh`):
`govulncheck ./...` для Go-графа и stdlib с анализом достижимости и
`pnpm audit --prod` для frontend. Версия `govulncheck` пиньтся в `versions.env`,
база уязвимостей подтягивается на каждом запуске. Аварийный выход —
`ALLOW_VULNERABLE_DEPENDENCIES=true`; результат уезжает в metadata пакета полем
`dependency_security_gate`.
- **Обновлены зависимости frontend, попадающие в браузерный бандл**: `axios`
1.3.4 → 1.20.0, плюс `lodash`/`lodash-es` через `pnpm.overrides` до 4.18.1.
Прямые зависимости и их диапазоны не менялись — двинулся только lockfile.
В production-графе не осталось уязвимостей уровня high и critical.
### Удалено
- **Четыре ключа таблицы `config` без единого потребителя** —
`HYSTERIA2_ENABLE` (жизненным циклом Hysteria владеет systemd),
`HYSTERIA2_CONFIG` (второй источник истины рядом с
`/etc/hysteria/config.yaml`, причём читался **первым**),
`HYSTERIA2_TRAFFIC_TIME` (настройка «период учёта трафика», которую не читал
никто: интервал сбора метрик задан в коде) и `HYSTERIA2_CONFIG_REMARK` (пустая
read-only строка). Строки удаляются миграцией `006_drop_dead_config_keys`.
Настоящую замену получил только последний: имя профиля в клиентской ссылке
теперь выводится из имени пира, а при его отсутствии — из публичного хоста.
После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`.
### Исправлено — предыдущий проход
- **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install` - **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install`
`preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста. `preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста.
+16 -2
View File
@@ -920,9 +920,10 @@ export GITHUB_TOKEN=<token>
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; 4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; 5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
6. прогоняет `go vet` и `go test` для HY2XS admin; 6. прогоняет `go vet` и `go test` для HY2XS admin;
7. формирует архив и прогоняет acceptance‑проверки. 7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit --prod`);
8. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–6 останавливает сборку до создания пакета. Любой сбой на шагах 1–7 останавливает сборку до создания пакета.
Переменные, управляющие выбором версии Hysteria: Переменные, управляющие выбором версии Hysteria:
@@ -934,6 +935,19 @@ export GITHUB_TOKEN=<token>
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass | | `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл | | `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
Переменные проверки зависимостей:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `ALLOW_VULNERABLE_DEPENDENCIES` | `false` | Выпустить релиз, зная о найденной уязвимости. Решение человека, а не поведение по умолчанию; попадает в metadata как `dependency_security_gate=accepted-risk` |
| `SKIP_SECURITY_SCAN` | `false` | Полностью пропустить шаг; в metadata пишется `skipped` |
Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы.
Первая следит, что зафиксированные версии **согласованы между собой**; вторая —
что про эти версии **не стало известно плохого**. Зафиксированный граф не
стареет только на бумаге: advisory по нему выходят и после фиксации, а сборка
релиза — единственный момент, когда это расхождение ловится дёшево.
Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка
берётся из production‑генератора, а не из отдельной реализации внутри теста): берётся из production‑генератора, а не из отдельной реализации внутри теста):
+12 -5
View File
@@ -35,11 +35,18 @@ func run(cmd *cobra.Command, args []string) {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
for { // Цикла `for { runServer() }` здесь больше нет.
if err := runServer(port); err != nil { //
fmt.Println(err.Error()) // Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON
os.Exit(1) // делала StopServer(), runServer возвращался, и цикл поднимал сервис
} // заново, чтобы новое расписание попало в новый планировщик. Это был
// внутрипроцессный «перезапуск», из-за которого планировщики накапливались
// (см. service/cron_scheduler.go). Расписание теперь переносится на месте,
// перезапускать сервер незачем, а перезапуском упавшего юнита занимается
// systemd (Restart=on-failure) — он для этого и предназначен.
if err := runServer(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
} }
} }
+15 -2
View File
@@ -21,13 +21,26 @@ func init() {
rootCmd.AddCommand(resetCmd) rootCmd.AddCommand(resetCmd)
} }
// Длины восстановительных учётных данных.
//
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
// единственной защитой оставался force_password_change. Пароль восстановления
// живёт до первого входа администратора, но до этого входа он полноценно
// пускает в панель, поэтому его стойкость должна соответствовать остальным
// секретам продукта, а не минимально допустимой.
const (
resetUsernameLength = 12
resetPasswordLength = 24
)
func runReset(cmd *cobra.Command, args []string) { func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(6) username, err := util.RandomString(resetUsernameLength)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
} }
password, err := util.RandomString(6) password, err := util.RandomString(resetPasswordLength)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) os.Exit(1)
+45 -7
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -14,9 +15,11 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
) )
func resolveUiBindHost() (string, error) { func resolveUiBindHost() (string, error) {
@@ -69,9 +72,14 @@ func runServer(port string) error {
if err := dao.InitSql(); err != nil { if err := dao.InitSql(); err != nil {
return err return err
} }
if err := middleware.InitCron(); err != nil { if err := service.InitCron(); err != nil {
return err return err
} }
// Порядок остановки обратный порядку регистрации: планировщик глушится
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
// и обратный порядок означал бы гарантированные «database is closed» на
// каждом штатном завершении сервиса.
defer service.StopCron()
if err := service.InitHysteria2(); err != nil { if err := service.InitHysteria2(); err != nil {
return err return err
} }
@@ -97,19 +105,49 @@ func runServer(port string) error {
// loopback и публикуется через SSH-туннель или reverse proxy, на котором // loopback и публикуется через SSH-туннель или reverse proxy, на котором
// TLS и заканчивается. // TLS и заканчивается.
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r) service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
if err := service.StartServer(); err != nil && err != http.ErrServerClosed {
logrus.Errorf("start server err: %v", err) // Штатное завершение по сигналу.
return errors.New("start server err") //
// Раньше SIGTERM от systemd просто убивал процесс: соединения обрывались
// на середине, SQLite закрывался не через defer, а вместе с процессом, и
// джобы планировщика могли быть остановлены посреди записи. Для сервиса,
// который ведёт учёт трафика в транзакциях, это не абстракция — обычный
// `systemctl restart` попадал в это окно.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() {
serverErr <- service.StartServer()
}()
select {
case err := <-serverErr:
return classifyServeError(err)
case <-ctx.Done():
logrus.Infof("shutdown signal received, stopping hy2xs-admin")
if err := service.StopServer(); err != nil {
return err
}
return classifyServeError(<-serverErr)
} }
return nil }
// classifyServeError отделяет штатное закрытие сервера от настоящего отказа.
func classifyServeError(err error) error {
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
logrus.Errorf("start server err: %v", err)
return errors.New("start server err")
} }
func releaseResource() { func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil { if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf(err.Error()) logrus.Errorf("%v", err)
} }
if err := service.ReleaseHysteria2(); err != nil { if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf(err.Error()) logrus.Errorf("%v", err)
} }
} }
+61 -18
View File
@@ -39,13 +39,31 @@ func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin
) )
} }
// UpdateConfigs применяет партию настроек по принципу «всё или ничего».
//
// Три прохода, и каждый отвечает за своё:
//
// 1. проверка партии целиком — права на ключ, дубликаты, значения;
// 2. одна транзакция базы;
// 3. применение к рантайму.
//
// Раньше проходов не было вовсе: цикл проверял очередной элемент и тут же его
// записывал. Партия «разрешённый ключ + запрещённый» применяла первый и
// возвращала ошибку на втором — оператор получал отказ на запрос, который
// систему уже изменил.
//
// Второе изменение того же места: смена расписания больше не роняет
// HTTP-сервер. Раньше здесь стоял `go service.StopServer()`, а точка входа
// крутила runServer в цикле, из-за чего на процессе накапливались планировщики
// (см. service/cron_scheduler.go).
func UpdateConfigs(c *gin.Context) { func UpdateConfigs(c *gin.Context) {
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
if err != nil { if err != nil {
return return
} }
needRestart := false updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos))
seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos))
for _, item := range configsUpdateDto.ConfigUpdateDtos { for _, item := range configsUpdateDto.ConfigUpdateDtos {
key := *item.Key key := *item.Key
@@ -63,42 +81,67 @@ func UpdateConfigs(c *gin.Context) {
return return
} }
if key == constant.ResetTrafficCron { // Один ключ дважды в одной партии — неоднозначный запрос: какое из
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) // двух значений считать намерением оператора, определить нельзя.
if err != nil { if _, duplicate := seen[key]; duplicate {
vo.Fail(err.Error(), c) vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c)
return return
}
if *resetTrafficCron.Value != value {
needRestart = true
}
} }
seen[key] = struct{}{}
if err = service.UpdateConfig(key, value); err != nil { if err = service.ValidateConfigValue(key, value); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
} }
if needRestart { if err = service.UpdateConfigs(updates); err != nil {
go func() { vo.Fail(err.Error(), c)
_ = service.StopServer() return
}() }
if err = applyRuntimeConfigUpdates(updates); err != nil {
vo.Fail(err.Error(), c)
return
} }
vo.Success(nil, c) vo.Success(nil, c)
} }
// applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма.
//
// Отказ здесь недостижим по построению: значения проверены тем же парсером,
// которым планировщик их разбирает, а сам планировщик поднимается в runServer
// до старта HTTP-сервера — то есть к моменту обработки запроса он всегда
// запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь
// сломают, отказ должен быть громким, а не молча пропавшей джобой.
func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error {
for _, item := range updates {
if item.Key != constant.ResetTrafficCron {
continue
}
if err := service.RescheduleResetTraffic(item.Value); err != nil {
return err
}
}
return nil
}
// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор. // isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор.
// //
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять // Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого // ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`. // ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
//
// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами:
// первым никто не управлял, второй был вторым источником истины рядом с
// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит
// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite.
func isOrchestratorManagedConfigKey(key string) bool { func isOrchestratorManagedConfigKey(key string) bool {
switch key { switch key {
case constant.Hysteria2Enable, case constant.Hysteria2TrafficStatsSecret:
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret:
return true return true
default: default:
return false return false
+154 -7
View File
@@ -5,11 +5,14 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/service"
) )
// Секреты, которые лежат в таблице `config` рядом с пользовательскими // Секреты, которые лежат в таблице `config` рядом с пользовательскими
@@ -72,7 +75,7 @@ func TestListConfigRefusesInternalKeys(t *testing.T) {
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос. // Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
func TestListConfigRefusesMixedBatch(t *testing.T) { func TestListConfigRefusesMixedBatch(t *testing.T) {
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{ result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
"keys": []string{constant.Hysteria2TrafficTime, constant.PeerSecretEncryptionKey}, "keys": []string{constant.ResetTrafficCron, constant.PeerSecretEncryptionKey},
}) })
if result.Type != "no" { if result.Type != "no" {
@@ -109,8 +112,6 @@ func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
// такого ключа»: оператор должен понять, что менять их надо через reconfigure. // такого ключа»: оператор должен понять, что менять их надо через reconfigure.
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) { func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
for _, key := range []string{ for _, key := range []string{
constant.Hysteria2Enable,
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret, constant.Hysteria2TrafficStatsSecret,
} { } {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
@@ -126,6 +127,23 @@ func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
} }
} }
// Удалённые ключи обязаны отклоняться как неизвестные, а не приниматься.
func TestUpdateConfigsRefusesRemovedKeys(t *testing.T) {
for _, key := range []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
} {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
})
if result.Type != "no" {
t.Errorf("удалённый ключ %s принят на запись: %+v", key, result)
}
}
}
func TestUpdateConfigsRefusesUnknownKey(t *testing.T) { func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}}, "configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
@@ -139,17 +157,146 @@ func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
} }
} }
// Отказ на любой записи партии обязан отменить партию целиком: иначе первые // newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась
// ключи применились бы, а оператор получил бы ошибку. // частично» без базы бессмысленна — именно состояние базы и есть предмет
func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) { // утверждения. Схема поднимается отдельно от InitSql: bootstrap-администратор
// и bootstrap-пир здесь не нужны.
func newConfigTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() {
_ = dao.CloseSqliteDB()
})
}
func storedConfigValue(t *testing.T, key string) string {
t.Helper()
config, err := dao.GetConfig("key = ?", key)
if err != nil {
t.Fatalf("не удалось прочитать %s: %v", key, err)
}
if config.Value == nil {
return ""
}
return *config.Value
}
// Отказ на любой записи партии обязан отменить партию ЦЕЛИКОМ.
//
// Разрешённый ключ стоит ПЕРВЫМ намеренно. Предыдущая версия этого теста
// ставила запрещённый ключ первым, поэтому проходила и на реализации, которая
// проверяла и записывала настройки в одном цикле: до второго элемента дело не
// доходило. Поймать частичное применение такой порядок неспособен по
// построению, и состояние базы тест тоже не смотрел.
func TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{ "configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
{"key": constant.JwtSecret, "value": "attacker-controlled"}, {"key": constant.JwtSecret, "value": "attacker-controlled"},
{"key": constant.Hysteria2TrafficTime, "value": "10"},
}, },
}) })
if result.Type != "no" { if result.Type != "no" {
t.Fatalf("партия с секретом не отклонена: %+v", result) t.Fatalf("партия с секретом не отклонена: %+v", result)
} }
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("отклонённая партия изменила %s: было %q, стало %q", constant.ResetTrafficCron, before, after)
}
if secret := storedConfigValue(t, constant.JwtSecret); secret == "attacker-controlled" {
t.Fatal("подписывающий ключ перезаписан отклонённой партией")
}
}
// Успешная партия обязана и сохраниться, и доехать до рантайма — БЕЗ
// перезапуска HTTP-сервера.
//
// Раньше новое расписание применялось единственным способом: обработчик
// вызывал StopServer(), точка входа поднимала runServer заново, и новый
// планировщик читал настройку из базы. Тест закрепляет обе половины замены:
// значение в базе изменилось, джоба перепланирована, число записей
// планировщика не выросло.
func TestUpdateConfigsAppliesScheduleWithoutRestart(t *testing.T) {
newConfigTestDB(t)
if err := service.InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
t.Cleanup(service.StopCron)
before := service.CronEntryCount()
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
},
})
if result.Type != "ok" {
t.Fatalf("корректная партия отклонена: %+v", result)
}
if stored := storedConfigValue(t, constant.ResetTrafficCron); stored != "@weekly" {
t.Fatalf("значение не сохранено: %q", stored)
}
if !service.ResetTrafficScheduled() {
t.Fatal("новое расписание не применилось к планировщику")
}
if after := service.CronEntryCount(); after != before {
t.Fatalf("применение расписания добавило джобы: было %d, стало %d", before, after)
}
}
// Невалидное значение в партии обязано отменить её так же, как запрещённый
// ключ: разница между «нельзя писать сюда» и «нельзя писать это» касается
// только текста ошибки, но не последствий для базы.
func TestUpdateConfigsRefusesWholeBatchOnInvalidValue(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "какая-нибудь ерунда"},
},
})
if result.Type != "no" {
t.Fatalf("невалидное cron-выражение принято: %+v", result)
}
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("невалидное значение попало в базу: было %q, стало %q", before, after)
}
}
// Один ключ дважды в партии — неоднозначный запрос: какое из двух значений
// считать намерением оператора, определить нельзя.
func TestUpdateConfigsRefusesDuplicateKeys(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
{"key": constant.ResetTrafficCron, "value": "@monthly"},
},
})
if result.Type != "no" {
t.Fatalf("партия с дубликатом ключа принята: %+v", result)
}
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("партия с дубликатом изменила базу: было %q, стало %q", before, after)
}
} }
+31 -10
View File
@@ -43,6 +43,14 @@ func LogSystem(c *gin.Context) {
vo.Fail("Unable to unmarshal log data", c) vo.Fail("Unable to unmarshal log data", c)
continue continue
} }
// Собственный журнал санитизируется так же, как чужой.
//
// Раньше через SanitizeLogText проходил только журнал Hysteria: он
// «чужой», а свой мы якобы контролируем. Контроль этот держался на
// внимательности — ровно до logrus.Warnf с паролем администратора в
// dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах
// записи, а на случай следующего такого места.
logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg)
logSystemVos = append(logSystemVos, logSystemVo) logSystemVos = append(logSystemVos, logSystemVo)
} }
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{ vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
@@ -80,10 +88,26 @@ func ExportLog(c *gin.Context) {
} }
var fileName string var fileName string
var filePath string
if *logExportDto.Option == 0 { if *logExportDto.Option == 0 {
fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405")) fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405"))
filePath = constant.SystemLogPath
// Журнал админки отдаётся санитизированным, а не файлом «как есть».
//
// Раньше здесь стоял c.File(constant.SystemLogPath): оператор скачивал
// сырой /var/log/hy2xs/hy2xs-admin.log. Журнал Hysteria при этом
// проходил через SanitizeLogText — то есть чужому журналу продукт не
// доверял, а своему доверял. Асимметрия ничем не обоснована: файл в
// обоих случаях покидает сервер и пересылается в переписке.
output, exportErr := service.ExportAdminLog()
if exportErr != nil {
vo.Fail("log file not exist", c)
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Data(200, "text/plain; charset=utf-8", []byte(output))
return
} else if *logExportDto.Option == 1 { } else if *logExportDto.Option == 1 {
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405")) fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
output, exportErr := service.ExportHysteriaJournalLogs(5000) output, exportErr := service.ExportHysteriaJournalLogs(5000)
@@ -98,12 +122,9 @@ func ExportLog(c *gin.Context) {
return return
} }
if !util.Exists(filePath) { // Обе ветки выше завершаются return. Сюда попадает только неизвестное
vo.Fail("log file not exist", c) // значение option, и это отказ, а не отдача пустого файла: раньше здесь
return // оставался путь с пустым filePath, который сводился к тому же сообщению
} // окольной дорогой.
c.Header("Content-Type", "application/octet-stream") vo.Fail("unsupported log export option", c)
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
} }
+94 -8
View File
@@ -18,14 +18,100 @@ func SaveConfig(config entity.Config) (int64, error) {
} }
func UpdateConfig(keys []string, updates map[string]interface{}) error { func UpdateConfig(keys []string, updates map[string]interface{}) error {
if len(updates) > 0 { return updateConfigOn(sqliteDB, keys, updates)
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") }
if tx := sqliteDB.Model(&entity.Config{}).
Where("key in ?", keys). // UpsertConfigValue записывает значение ключа, создавая строку, если её нет.
Updates(updates); tx.Error != nil { //
logrus.Errorf("%v", tx.Error) // Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе
return errors.New(constant.SysError) // были неверны:
} //
// 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой
// загрузке печатало в журнал `duplicated key not allowed` уровня error, по
// одной строке на секрет. Оператор видел три ошибки на совершенно здоровом
// старте и не имел способа отличить их от настоящих;
//
// 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки
// не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает
// nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий
// получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET
// это означало бы подпись токенов ключом, которого нет в базе. Спасала
// только предварительная вставка пустых строк в seedBaseConfig — то есть
// корректность держалась на порядке выполнения миграций.
//
// Здесь решение принимается по RowsAffected, а не по наличию ошибки.
func UpsertConfigValue(key string, value string) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
tx := sqliteDB.Model(&entity.Config{}).
Where("key = ?", key).
Updates(map[string]interface{}{
"value": value,
"update_time": time.Now().Format("2006-01-02 15:04:05"),
})
if tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
if tx.RowsAffected > 0 {
return nil
}
remark := key
if _, err := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
return err
}
return nil
}
// ConfigTx — записи в таблицу `config` в границах одной единицы работы.
//
// Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть
// доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна
// и та же для глобального соединения и для транзакции, поэтому «обычный» и
// «транзакционный» пути не могут разойтись в поведении.
type ConfigTx interface {
UpdateConfig(keys []string, updates map[string]interface{}) error
}
type configTx struct {
db *gorm.DB
}
// WithConfigTx выполняет fn в одной транзакции: либо применяются все записи
// партии, либо ни одной.
//
// Что было. updateConfigs валидировал и записывал настройки в одном цикле:
// проверил элемент — записал, проверил следующий. Партия из разрешённого
// ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть
// оператор получал отказ на запрос, который систему уже изменил. Тест на этот
// случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу,
// поэтому поймать частичное применение был неспособен по построению.
func WithConfigTx(fn func(tx ConfigTx) error) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
return sqliteDB.Transaction(func(tx *gorm.DB) error {
return fn(configTx{db: tx})
})
}
func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error {
return updateConfigOn(c.db, keys, updates)
}
func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := db.Model(&entity.Config{}).
Where("key in ?", keys).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
} }
return nil return nil
} }
+130 -85
View File
@@ -95,6 +95,22 @@ func envBoolAsInt(name string, fallback int) int {
return fallback return fallback
} }
// ensureSecureBootstrapAdmin создаёт первую учётную запись администратора.
//
// Пароль приходит ТОЛЬКО из HY2XS_ADMIN_INITIAL_PASSWORD, и его отсутствие —
// отказ старта, а не повод придумать пароль самостоятельно.
//
// Что было. При пустом env админка генерировала пароль сама и печатала его
// двумя logrus.Warnf — то есть открытым текстом в /var/log/hy2xs/hy2xs-admin.log.
// Этот же файл отдаётся оператору кнопкой выгрузки журнала и попадает в
// diagnostics-бандл, который люди пересылают в переписке. Получался постоянный
// канал утечки учётных данных администратора.
//
// Второе, менее очевидное: сгенерированный здесь пароль не знает НИКТО, кроме
// журнала. Оркестратор пишет свой в /etc/hy2xs/bootstrap-admin.secret и в
// hy2xs.env, и именно он считается контрактом установки. Значит, попадание
// сюда означает не «нужно что-то придумать», а повреждённый контракт запуска —
// и правильная реакция на него громкая, а не молчаливая.
func ensureSecureBootstrapAdmin() error { func ensureSecureBootstrapAdmin() error {
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER")) adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
if adminUser == "" { if adminUser == "" {
@@ -105,14 +121,16 @@ func ensureSecureBootstrapAdmin() error {
} }
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
passwordGenerated := false
if adminPassword == "" { if adminPassword == "" {
password, pwdErr := util.RandomString(18) return errors.New(
if pwdErr != nil { "HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
return pwdErr "Это признак повреждённого контракта запуска: пароль первого администратора\n" +
} "задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" +
adminPassword = password "/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" +
passwordGenerated = true "будет: такой пароль не знал бы никто, кроме журнала.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
} }
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1) status := int64(1)
@@ -135,23 +153,22 @@ func ensureSecureBootstrapAdmin() error {
if _, saveErr := SaveAdminUser(account); saveErr != nil { if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr return saveErr
} }
if passwordGenerated {
logrus.Warnf("Initial admin username: %s", adminUser)
logrus.Warnf("Initial admin password: %s", adminPassword)
}
return nil return nil
} }
// ensureSecureBootstrapPeer создаёт пир установщика.
//
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
// файлом, и первая же проверка подключения после установки провалилась бы —
// на корректном во всём остальном сервере.
//
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
// без переменной падал бы на ровном месте.
func ensureSecureBootstrapPeer() error { func ensureSecureBootstrapPeer() error {
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if bootstrapSecret == "" {
generated, err := util.RandomString(24)
if err != nil {
return err
}
bootstrapSecret = generated
}
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil { if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
return nil return nil
} }
@@ -159,6 +176,19 @@ func ensureSecureBootstrapPeer() error {
return nil return nil
} }
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if bootstrapSecret == "" {
return errors.New(
"HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" +
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
"подключения после установки провалилась бы.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
secretDigest, err := buildPeerSecretDigest(bootstrapSecret) secretDigest, err := buildPeerSecretDigest(bootstrapSecret)
if err != nil { if err != nil {
return err return err
@@ -205,15 +235,23 @@ func ensureSecureBootstrapPeer() error {
} }
func buildPeerSecretDigest(rawSecret string) (string, error) { func buildPeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := getOrCreatePeerSecretDigestKey() secretKey, err := GetOrCreatePeerSecretDigestKey()
if err != nil { if err != nil {
return "", err return "", err
} }
return util.HmacSHA256Hex(rawSecret, secretKey), nil return util.HmacSHA256Hex(rawSecret, secretKey), nil
} }
func getOrCreatePeerSecretEncryptionKey() ([]byte, error) { // GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32) // пиров, при необходимости создавая его.
//
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
// ними означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и обнаружилось бы это на живых пирах.
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -221,41 +259,42 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
if decErr == nil { if decErr == nil {
return decoded, nil return decoded, nil
} }
// Ключ, записанный не в base64: приводим к стабильному представлению один
// раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров
// перестали бы расшифровываться.
plain := []byte(strings.TrimSpace(raw)) plain := []byte(strings.TrimSpace(raw))
if len(plain) < 32 { if len(plain) < 32 {
return nil, errors.New("invalid peer secret encryption key") return nil, errors.New("invalid peer secret encryption key")
} }
plain = plain[:32] plain = plain[:32]
encoded := base64.StdEncoding.EncodeToString(plain) if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil {
if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil {
return nil, err return nil, err
} }
return plain, nil return plain, nil
} }
func encryptBootstrapPeerSecret(rawSecret string) (string, error) { func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
key, err := getOrCreatePeerSecretEncryptionKey() key, err := GetOrCreatePeerSecretEncryptionKey()
if err != nil { if err != nil {
return "", err return "", err
} }
return util.EncryptAESGCM(rawSecret, key) return util.EncryptAESGCM(rawSecret, key)
} }
func getOrCreateConfigKey(key string, size int) (string, error) { // GetOrCreateConfigSecret читает секрет из таблицы `config`, создавая его при
// первом обращении. Единственная реализация этой операции в продукте.
func GetOrCreateConfigSecret(key string, size int) (string, error) {
cfg, err := GetConfig("key = ?", key) cfg, err := GetConfig("key = ?", key)
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
return strings.TrimSpace(*cfg.Value), nil return strings.TrimSpace(*cfg.Value), nil
} }
raw, genErr := util.RandomString(size) value, genErr := util.RandomString(size)
if genErr != nil { if genErr != nil {
return "", genErr return "", genErr
} }
value := raw if err := UpsertConfigValue(key, value); err != nil {
remark := key return "", err
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil {
if updErr := UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil {
return "", updErr
}
} }
return value, nil return value, nil
} }
@@ -300,6 +339,7 @@ func runMigrations() error {
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit}, {version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables}, {version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
{version: "005_metric_sample", apply: migrateMetricSampleTable}, {version: "005_metric_sample", apply: migrateMetricSampleTable},
{version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys},
} }
for _, m := range migrations { for _, m := range migrations {
@@ -322,12 +362,16 @@ func runMigrations() error {
func seedBaseConfig() error { func seedBaseConfig() error {
// Сетевых настроек панели здесь нет: ими владеет оркестратор. // Сетевых настроек панели здесь нет: ими владеет оркестратор.
//
// HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
// HYSTERIA2_CONFIG_REMARK удалены: ни один из них не имел потребителя в
// рантайме, см. model/constant/config.go. Строки этих ключей на уже
// установленных машинах убирает миграция 006_drop_dead_config_keys.
//
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
// действительно владеет панель.
defaults := map[string]string{ defaults := map[string]string{
constant.JwtSecret: "", constant.JwtSecret: "",
constant.Hysteria2Enable: "0",
constant.Hysteria2Config: "",
constant.Hysteria2TrafficTime: "10",
constant.Hysteria2ConfigRemark: "",
constant.ResetTrafficCron: "0 0 * * *", constant.ResetTrafficCron: "0 0 * * *",
constant.Hysteria2TrafficStatsSecret: "", constant.Hysteria2TrafficStatsSecret: "",
constant.PeerSecretKey: "", constant.PeerSecretKey: "",
@@ -363,6 +407,34 @@ func migrateBaseConfig() error {
return seedBaseConfig() return seedBaseConfig()
} }
// migrateDropDeadConfigKeys убирает из таблицы `config` ключи, у которых не
// осталось ни одного потребителя.
//
// Миграция нужна не ради места в базе, а ради того же инварианта, что и
// allowlist: строка в таблице секретов, которую никто не читает и не пишет,
// рано или поздно кем-нибудь прочитается. Ключи перечислены здесь строковыми
// литералами намеренно — соответствующих констант в коде уже нет, и появиться
// они не должны.
//
// Миграция безопасна на чистой установке (удалять нечего) и идемпотентна:
// применяется один раз и отмечается в schema_migrations. Номера предыдущих
// миграций не тронуты — они уже записаны на установленных машинах.
func migrateDropDeadConfigKeys() error {
deadKeys := []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
}
for _, key := range deadKeys {
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", key); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func migrationApplied(version string) (bool, error) { func migrationApplied(version string) (bool, error) {
var count int64 var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil { if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil {
@@ -428,34 +500,17 @@ func getOrCreateJwtSecret() (string, error) {
if secErr != nil { if secErr != nil {
return "", secErr return "", secErr
} }
if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil { if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
key := constant.JwtSecret return "", err
remark := "JWT signing secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return "", saveErr
}
} }
return secret, nil return secret, nil
} }
func getOrCreatePeerSecretDigestKey() (string, error) { // GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil { // пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { // логики жила в service/peer_secret.go под именем GetPeerSecretKey.
return strings.TrimSpace(*existing.Value), nil func GetOrCreatePeerSecretDigestKey() (string, error) {
} return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
}
keyValue, keyErr := util.RandomString(48)
if keyErr != nil {
return "", keyErr
}
if err := UpdateConfig([]string{constant.PeerSecretKey}, map[string]interface{}{"value": keyValue}); err != nil {
key := constant.PeerSecretKey
remark := "Peer secret digest key"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &keyValue, Remark: &remark}); saveErr != nil {
return "", saveErr
}
}
return keyValue, nil
} }
func migrateTrafficTables() error { func migrateTrafficTables() error {
@@ -542,43 +597,33 @@ func tableExists(tableName string) bool {
} }
return count > 0 return count > 0
} }
// ensureTrafficStatsSecret синхронизирует machine token с окружением.
//
// Владелец значения — оркестратор: он же кладёт этот секрет в
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а
// собственная генерация — только для случая, когда переменной нет вовсе.
//
// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue:
// каждая из них по-своему решала «есть строка или нет», и две из четырёх
// решали неверно (см. комментарий к UpsertConfigValue).
func ensureTrafficStatsSecret() error { func ensureTrafficStatsSecret() error {
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET")) envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
if envSecret != "" { if envSecret != "" {
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil { return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
if existing.Value != nil && strings.TrimSpace(*existing.Value) != envSecret {
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": envSecret})
}
return nil
}
key := constant.Hysteria2TrafficStatsSecret
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &envSecret, Remark: &remark}); saveErr != nil {
return saveErr
}
return nil
} }
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil { if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil return nil
} }
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret})
} }
secret, secErr := util.RandomString(32) secret, secErr := util.RandomString(32)
if secErr != nil { if secErr != nil {
return secErr return secErr
} }
key := constant.Hysteria2TrafficStatsSecret return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret)
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return saveErr
}
return nil
} }
func CloseSqliteDB() error { func CloseSqliteDB() error {
+8 -2
View File
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^1.0.0", "@element-plus/icons-vue": "^1.0.0",
"@vueuse/core": "^9.1.1", "@vueuse/core": "^9.1.1",
"axios": "^1.3.4", "axios": "^1.20.0",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"element-plus": "^2.3.1", "element-plus": "^2.3.1",
@@ -42,7 +42,7 @@
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0", "eslint-plugin-vue": "^9.9.0",
"postcss": "^8.4.21", "postcss": "^8.5.26",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6", "postcss-scss": "^4.0.6",
"prettier": "^2.8.7", "prettier": "^2.8.7",
@@ -63,6 +63,12 @@
"vite-plugin-svg-icons": "^2.0.1", "vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^0.35.0" "vue-tsc": "^0.35.0"
}, },
"pnpm": {
"overrides": {
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1"
}
},
"engines": { "engines": {
"node": ">=18.12.0" "node": ">=18.12.0"
} }
+212 -77
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
importers: importers:
.: .:
@@ -15,8 +19,8 @@ importers:
specifier: ^9.1.1 specifier: ^9.1.1
version: 9.1.1(vue@3.2.45) version: 9.1.1(vue@3.2.45)
axios: axios:
specifier: ^1.3.4 specifier: ^1.20.0
version: 1.3.4 version: 1.20.0
copy-to-clipboard: copy-to-clipboard:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
@@ -74,7 +78,7 @@ importers:
version: 4.0.0(vite@4.3.1(@types/node@20.7.0)(sass@1.58.3))(vue@3.2.45) version: 4.0.0(vite@4.3.1(@types/node@20.7.0)(sass@1.58.3))(vue@3.2.45)
autoprefixer: autoprefixer:
specifier: ^10.4.13 specifier: ^10.4.13
version: 10.4.13(postcss@8.4.21) version: 10.4.13(postcss@8.5.26)
eslint: eslint:
specifier: ^8.34.0 specifier: ^8.34.0
version: 8.34.0 version: 8.34.0
@@ -88,14 +92,14 @@ importers:
specifier: ^9.9.0 specifier: ^9.9.0
version: 9.9.0(eslint@8.34.0) version: 9.9.0(eslint@8.34.0)
postcss: postcss:
specifier: ^8.4.21 specifier: ^8.5.26
version: 8.4.21 version: 8.5.26
postcss-html: postcss-html:
specifier: ^1.5.0 specifier: ^1.5.0
version: 1.5.0 version: 1.5.0
postcss-scss: postcss-scss:
specifier: ^4.0.6 specifier: ^4.0.6
version: 4.0.6(postcss@8.4.21) version: 4.0.6(postcss@8.5.26)
prettier: prettier:
specifier: ^2.8.7 specifier: ^2.8.7
version: 2.8.7 version: 2.8.7
@@ -113,7 +117,7 @@ importers:
version: 4.0.0(stylelint@15.5.0(typescript@4.9.3)) version: 4.0.0(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-recommended-scss: stylelint-config-recommended-scss:
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.0.1(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) version: 9.0.1(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-recommended-vue: stylelint-config-recommended-vue:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)) version: 1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3))
@@ -122,7 +126,7 @@ importers:
version: 32.0.0(stylelint@15.5.0(typescript@4.9.3)) version: 32.0.0(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-standard-scss: stylelint-config-standard-scss:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) version: 8.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3))
typescript: typescript:
specifier: ^4.9.3 specifier: ^4.9.3
version: 4.9.3 version: 4.9.3
@@ -532,8 +536,8 @@ packages:
rollup: rollup:
optional: true optional: true
'@sxzz/popperjs-es@2.11.7': '@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==} resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@trysound/sax@0.2.0': '@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
@@ -881,8 +885,8 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
axios@1.3.4: axios@1.20.0:
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==} resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -937,6 +941,10 @@ packages:
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0: callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1189,6 +1197,10 @@ packages:
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2: duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@@ -1231,6 +1243,22 @@ packages:
error-ex@1.3.2: error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.17.19: esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1414,8 +1442,8 @@ packages:
flatted@3.2.9: flatted@3.2.9:
resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==}
follow-redirects@1.15.3: follow-redirects@1.16.0:
resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
peerDependencies: peerDependencies:
debug: '*' debug: '*'
@@ -1427,8 +1455,8 @@ packages:
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
form-data@4.0.0: form-data@4.0.6:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
fraction.js@4.3.6: fraction.js@4.3.6:
@@ -1457,10 +1485,21 @@ packages:
function-bind@1.1.1: function-bind@1.1.1:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2: gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@6.0.1: get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1512,6 +1551,10 @@ packages:
globjoin@0.1.4: globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1542,6 +1585,14 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
has-value@0.3.1: has-value@0.3.1:
resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==} resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1565,6 +1616,10 @@ packages:
hash-sum@2.0.0: hash-sum@2.0.0:
resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==} resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
he@1.2.0: he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
@@ -1848,15 +1903,15 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash-es@4.17.21: lodash-es@4.18.1:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3: lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies: peerDependencies:
'@types/lodash-es': '*' '@types/lodash-es': '*'
lodash: '*' lodash: '>=4.18.1'
lodash-es: '*' lodash-es: '>=4.18.1'
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -1864,8 +1919,8 @@ packages:
lodash.truncate@4.4.2: lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash@4.17.21: lodash@4.18.1:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1901,6 +1956,10 @@ packages:
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mathml-tag-names@2.1.3: mathml-tag-names@2.1.3:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -2008,8 +2067,8 @@ packages:
ms@2.1.2: ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
nanoid@3.3.6: nanoid@3.3.18:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@@ -2159,6 +2218,9 @@ packages:
picocolors@1.0.0: picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1: picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
@@ -2228,8 +2290,8 @@ packages:
resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==} resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
postcss@8.4.21: postcss@8.5.26:
resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
posthtml-parser@0.2.1: posthtml-parser@0.2.1:
@@ -2265,8 +2327,9 @@ packages:
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
proxy-from-env@1.1.0: proxy-from-env@2.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
prr@1.0.1: prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@@ -2458,6 +2521,10 @@ packages:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map-resolve@0.5.3: source-map-resolve@0.5.3:
resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==}
deprecated: See https://github.com/lydell/source-map-resolve#deprecated deprecated: See https://github.com/lydell/source-map-resolve#deprecated
@@ -3389,7 +3456,7 @@ snapshots:
optionalDependencies: optionalDependencies:
rollup: 3.29.3 rollup: 3.29.3
'@sxzz/popperjs-es@2.11.7': {} '@sxzz/popperjs-es@2.11.8': {}
'@trysound/sax@0.2.0': {} '@trysound/sax@0.2.0': {}
@@ -3682,7 +3749,7 @@ snapshots:
'@vue/shared': 3.2.45 '@vue/shared': 3.2.45
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.25.9 magic-string: 0.25.9
postcss: 8.4.21 postcss: 8.5.26
source-map: 0.6.1 source-map: 0.6.1
'@vue/compiler-sfc@3.3.4': '@vue/compiler-sfc@3.3.4':
@@ -3695,7 +3762,7 @@ snapshots:
'@vue/shared': 3.3.4 '@vue/shared': 3.3.4
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.30.3 magic-string: 0.30.3
postcss: 8.4.21 postcss: 8.5.26
source-map-js: 1.0.2 source-map-js: 1.0.2
'@vue/compiler-ssr@3.2.45': '@vue/compiler-ssr@3.2.45':
@@ -3843,23 +3910,25 @@ snapshots:
atob@2.1.2: {} atob@2.1.2: {}
autoprefixer@10.4.13(postcss@8.4.21): autoprefixer@10.4.13(postcss@8.5.26):
dependencies: dependencies:
browserslist: 4.21.11 browserslist: 4.21.11
caniuse-lite: 1.0.30001539 caniuse-lite: 1.0.30001539
fraction.js: 4.3.6 fraction.js: 4.3.6
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.0.0 picocolors: 1.0.0
postcss: 8.4.21 postcss: 8.5.26
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
axios@1.3.4: axios@1.20.0:
dependencies: dependencies:
follow-redirects: 1.15.3 follow-redirects: 1.16.0
form-data: 4.0.0 form-data: 4.0.6
proxy-from-env: 1.1.0 https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- supports-color
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
@@ -3948,6 +4017,11 @@ snapshots:
union-value: 1.0.1 union-value: 1.0.1
unset-value: 1.0.0 unset-value: 1.0.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {} callsites@3.1.0: {}
camelcase-keys@6.2.2: camelcase-keys@6.2.2:
@@ -4197,6 +4271,12 @@ snapshots:
dotenv@16.3.1: {} dotenv@16.3.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
duplexer@0.1.2: {} duplexer@0.1.2: {}
echarts@5.6.0: echarts@5.6.0:
@@ -4211,16 +4291,16 @@ snapshots:
'@ctrl/tinycolor': 3.6.1 '@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.1.0(vue@3.2.45) '@element-plus/icons-vue': 2.1.0(vue@3.2.45)
'@floating-ui/dom': 1.5.3 '@floating-ui/dom': 1.5.3
'@popperjs/core': '@sxzz/popperjs-es@2.11.7' '@popperjs/core': '@sxzz/popperjs-es@2.11.8'
'@types/lodash': 4.14.199 '@types/lodash': 4.14.199
'@types/lodash-es': 4.17.9 '@types/lodash-es': 4.17.9
'@vueuse/core': 9.1.1(vue@3.2.45) '@vueuse/core': 9.1.1(vue@3.2.45)
async-validator: 4.2.5 async-validator: 4.2.5
dayjs: 1.11.10 dayjs: 1.11.10
escape-html: 1.0.3 escape-html: 1.0.3
lodash: 4.17.21 lodash: 4.18.1
lodash-es: 4.17.21 lodash-es: 4.18.1
lodash-unified: 1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.17.21)(lodash@4.17.21) lodash-unified: 1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.18.1)(lodash@4.18.1)
memoize-one: 6.0.0 memoize-one: 6.0.0
normalize-wheel-es: 1.2.0 normalize-wheel-es: 1.2.0
vue: 3.2.45 vue: 3.2.45
@@ -4251,6 +4331,21 @@ snapshots:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
esbuild@0.17.19: esbuild@0.17.19:
optionalDependencies: optionalDependencies:
'@esbuild/android-arm': 0.17.19 '@esbuild/android-arm': 0.17.19
@@ -4505,14 +4600,16 @@ snapshots:
flatted@3.2.9: {} flatted@3.2.9: {}
follow-redirects@1.15.3: {} follow-redirects@1.16.0: {}
for-in@1.0.2: {} for-in@1.0.2: {}
form-data@4.0.0: form-data@4.0.6:
dependencies: dependencies:
asynckit: 0.4.0 asynckit: 0.4.0
combined-stream: 1.0.8 combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35 mime-types: 2.1.35
fraction.js@4.3.6: {} fraction.js@4.3.6: {}
@@ -4538,8 +4635,28 @@ snapshots:
function-bind@1.1.1: {} function-bind@1.1.1: {}
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
get-stream@6.0.1: {} get-stream@6.0.1: {}
get-value@2.0.6: {} get-value@2.0.6: {}
@@ -4608,6 +4725,8 @@ snapshots:
globjoin@0.1.4: {} globjoin@0.1.4: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
grapheme-splitter@1.0.4: {} grapheme-splitter@1.0.4: {}
@@ -4628,6 +4747,12 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
has-value@0.3.1: has-value@0.3.1:
dependencies: dependencies:
get-value: 2.0.6 get-value: 2.0.6
@@ -4653,6 +4778,10 @@ snapshots:
hash-sum@2.0.0: {} hash-sum@2.0.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
he@1.2.0: {} he@1.2.0: {}
hookable@5.5.3: {} hookable@5.5.3: {}
@@ -4885,19 +5014,19 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash-es@4.17.21: {} lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.17.21)(lodash@4.17.21): lodash-unified@1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.18.1)(lodash@4.18.1):
dependencies: dependencies:
'@types/lodash-es': 4.17.9 '@types/lodash-es': 4.17.9
lodash: 4.17.21 lodash: 4.18.1
lodash-es: 4.17.21 lodash-es: 4.18.1
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.truncate@4.4.2: {} lodash.truncate@4.4.2: {}
lodash@4.17.21: {} lodash@4.18.1: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
@@ -4929,6 +5058,8 @@ snapshots:
dependencies: dependencies:
object-visit: 1.0.1 object-visit: 1.0.1
math-intrinsics@1.1.0: {}
mathml-tag-names@2.1.3: {} mathml-tag-names@2.1.3: {}
mdn-data@2.0.14: {} mdn-data@2.0.14: {}
@@ -5047,7 +5178,7 @@ snapshots:
ms@2.1.2: {} ms@2.1.2: {}
nanoid@3.3.6: {} nanoid@3.3.18: {}
nanomatch@1.2.13: nanomatch@1.2.13:
dependencies: dependencies:
@@ -5197,6 +5328,8 @@ snapshots:
picocolors@1.0.0: {} picocolors@1.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
pinia@2.0.33(typescript@4.9.3)(vue@3.2.45): pinia@2.0.33(typescript@4.9.3)(vue@3.2.45):
@@ -5219,8 +5352,8 @@ snapshots:
dependencies: dependencies:
htmlparser2: 8.0.2 htmlparser2: 8.0.2
js-tokens: 8.0.2 js-tokens: 8.0.2
postcss: 8.4.21 postcss: 8.5.26
postcss-safe-parser: 6.0.0(postcss@8.4.21) postcss-safe-parser: 6.0.0(postcss@8.5.26)
postcss-import-resolver@2.0.0: postcss-import-resolver@2.0.0:
dependencies: dependencies:
@@ -5234,22 +5367,22 @@ snapshots:
postcss-resolve-nested-selector@0.1.1: {} postcss-resolve-nested-selector@0.1.1: {}
postcss-safe-parser@6.0.0(postcss@8.4.21): postcss-safe-parser@6.0.0(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.4.21 postcss: 8.5.26
postcss-scss@4.0.6(postcss@8.4.21): postcss-scss@4.0.6(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.4.21 postcss: 8.5.26
postcss-selector-parser@6.0.13: postcss-selector-parser@6.0.13:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
postcss-sorting@8.0.2(postcss@8.4.21): postcss-sorting@8.0.2(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.4.21 postcss: 8.5.26
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
@@ -5260,11 +5393,11 @@ snapshots:
source-map: 0.5.7 source-map: 0.5.7
supports-color: 3.2.3 supports-color: 3.2.3
postcss@8.4.21: postcss@8.5.26:
dependencies: dependencies:
nanoid: 3.3.6 nanoid: 3.3.18
picocolors: 1.0.0 picocolors: 1.1.1
source-map-js: 1.0.2 source-map-js: 1.2.1
posthtml-parser@0.2.1: posthtml-parser@0.2.1:
dependencies: dependencies:
@@ -5299,7 +5432,7 @@ snapshots:
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
proxy-from-env@1.1.0: {} proxy-from-env@2.1.0: {}
prr@1.0.1: {} prr@1.0.1: {}
@@ -5487,6 +5620,8 @@ snapshots:
source-map-js@1.0.2: {} source-map-js@1.0.2: {}
source-map-js@1.2.1: {}
source-map-resolve@0.5.3: source-map-resolve@0.5.3:
dependencies: dependencies:
atob: 2.1.2 atob: 2.1.2
@@ -5578,23 +5713,23 @@ snapshots:
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-order: 6.0.3(stylelint@15.5.0(typescript@4.9.3)) stylelint-order: 6.0.3(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-recommended-scss@10.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): stylelint-config-recommended-scss@10.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
postcss-scss: 4.0.6(postcss@8.4.21) postcss-scss: 4.0.6(postcss@8.5.26)
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-config-recommended: 11.0.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-recommended: 11.0.0(stylelint@15.5.0(typescript@4.9.3))
stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3))
optionalDependencies: optionalDependencies:
postcss: 8.4.21 postcss: 8.5.26
stylelint-config-recommended-scss@9.0.1(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): stylelint-config-recommended-scss@9.0.1(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
postcss-scss: 4.0.6(postcss@8.4.21) postcss-scss: 4.0.6(postcss@8.5.26)
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-config-recommended: 10.0.1(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-recommended: 10.0.1(stylelint@15.5.0(typescript@4.9.3))
stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3))
optionalDependencies: optionalDependencies:
postcss: 8.4.21 postcss: 8.5.26
stylelint-config-recommended-vue@1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)): stylelint-config-recommended-vue@1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
@@ -5616,13 +5751,13 @@ snapshots:
dependencies: dependencies:
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-config-standard-scss@8.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): stylelint-config-standard-scss@8.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-config-recommended-scss: 10.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-recommended-scss: 10.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-standard: 32.0.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-standard: 32.0.0(stylelint@15.5.0(typescript@4.9.3))
optionalDependencies: optionalDependencies:
postcss: 8.4.21 postcss: 8.5.26
stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)): stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
@@ -5631,8 +5766,8 @@ snapshots:
stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)): stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)):
dependencies: dependencies:
postcss: 8.4.21 postcss: 8.5.26
postcss-sorting: 8.0.2(postcss@8.4.21) postcss-sorting: 8.0.2(postcss@8.5.26)
stylelint: 15.5.0(typescript@4.9.3) stylelint: 15.5.0(typescript@4.9.3)
stylelint-scss@4.7.0(stylelint@15.5.0(typescript@4.9.3)): stylelint-scss@4.7.0(stylelint@15.5.0(typescript@4.9.3)):
@@ -5672,10 +5807,10 @@ snapshots:
micromatch: 4.0.5 micromatch: 4.0.5
normalize-path: 3.0.0 normalize-path: 3.0.0
picocolors: 1.0.0 picocolors: 1.0.0
postcss: 8.4.21 postcss: 8.5.26
postcss-media-query-parser: 0.2.3 postcss-media-query-parser: 0.2.3
postcss-resolve-nested-selector: 0.1.1 postcss-resolve-nested-selector: 0.1.1
postcss-safe-parser: 6.0.0(postcss@8.4.21) postcss-safe-parser: 6.0.0(postcss@8.5.26)
postcss-selector-parser: 6.0.13 postcss-selector-parser: 6.0.13
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
resolve-from: 5.0.0 resolve-from: 5.0.0
@@ -6010,7 +6145,7 @@ snapshots:
vite@4.3.1(@types/node@20.7.0)(sass@1.58.3): vite@4.3.1(@types/node@20.7.0)(sass@1.58.3):
dependencies: dependencies:
esbuild: 0.17.19 esbuild: 0.17.19
postcss: 8.4.21 postcss: 8.5.26
rollup: 3.29.3 rollup: 3.29.3
optionalDependencies: optionalDependencies:
'@types/node': 20.7.0 '@types/node': 20.7.0
@@ -6043,7 +6178,7 @@ snapshots:
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
esquery: 1.5.0 esquery: 1.5.0
lodash: 4.17.21 lodash: 4.18.1
semver: 7.5.4 semver: 7.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
+2 -7
View File
@@ -129,7 +129,7 @@ export default {
form: { form: {
namePlaceholder: "e.g. ivan-laptop", namePlaceholder: "e.g. ivan-laptop",
nameHint: nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret.", "Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team", remarkPlaceholder: "e.g. Ivan's laptop, sales team",
remarkHint: "Optional operator note. It is never shown to the client.", remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically", secretPlaceholder: "leave empty to generate automatically",
@@ -185,13 +185,11 @@ export default {
}, },
config: { config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure", orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
resetTrafficCron: "Reset traffic schedule task", resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip: resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3. An empty value disables the automatic reset",
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",
invalidTrafficTime: "Field must be a number with up to one decimal place",
}, },
monitor: { monitor: {
cpuPercent: "CPU Usage", cpuPercent: "CPU Usage",
@@ -229,7 +227,6 @@ export default {
hysteria2Version: "Hysteria2 Version", hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status", hysteria2Running: "Hysteria2 Status",
addOutbound: "Add Outbound", addOutbound: "Add Outbound",
extension: "Extension",
listen: "Listen", listen: "Listen",
tls: "TLS", tls: "TLS",
obfs: "Obfuscation", obfs: "Obfuscation",
@@ -245,8 +242,6 @@ export default {
http: "Traffic Stats API (HTTP)", http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade", masquerade: "Masquerade",
config: { config: {
enable: "Enable/Disable",
remark: "Remark",
listen: listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.", "When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type", tlsType: "TLS type",
+3 -8
View File
@@ -125,7 +125,7 @@ export default {
form: { form: {
namePlaceholder: "например, ivan-laptop", namePlaceholder: "например, ivan-laptop",
nameHint: nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.", "Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж", remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически", secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
@@ -181,13 +181,11 @@ export default {
}, },
config: { config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
resetTrafficCron: "Расписание сброса трафика", resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика", resetTrafficCronTip:
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой",
}, },
monitor: { monitor: {
cpuPercent: "CPU", cpuPercent: "CPU",
@@ -225,7 +223,6 @@ export default {
hysteria2Version: "Версия Hysteria2", hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2", hysteria2Running: "Статус Hysteria2",
addOutbound: "Добавить outbound", addOutbound: "Добавить outbound",
extension: "Расширение",
listen: "Адрес прослушивания", listen: "Адрес прослушивания",
tls: "TLS", tls: "TLS",
obfs: "Маскировка", obfs: "Маскировка",
@@ -241,8 +238,6 @@ export default {
http: "Traffic Stats API (HTTP)", http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade", masquerade: "Masquerade",
config: { config: {
enable: "Включить/отключить",
remark: "Комментарий",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.", listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS", tlsType: "Тип TLS",
tls: { tls: {
File diff suppressed because it is too large Load Diff
+53 -55
View File
@@ -1,65 +1,63 @@
// generated by unplugin-vue-components // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import "@vue/runtime-core"; import '@vue/runtime-core'
export {}; export {}
declare module "@vue/runtime-core" { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import("element-plus/es")["ElAlert"]; ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import("element-plus/es")["ElAvatar"]; ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import("element-plus/es")["ElButton"]; ElButton: typeof import('element-plus/es')['ElButton']
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']
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']
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"]; ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"]; ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import("element-plus/es")["ElEmpty"]; ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import("element-plus/es")["ElForm"]; ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import("element-plus/es")["ElFormItem"]; ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import("element-plus/es")["ElInput"]; ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"]; ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import("element-plus/es")["ElMenu"]; ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"]; ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import("element-plus/es")["ElOption"]; ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import("element-plus/es")["ElPagination"]; ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import("element-plus/es")["ElProgress"]; ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"]; ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"]; ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
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']
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"]; ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import("element-plus/es")["ElTabs"]; ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import("element-plus/es")["ElTag"]; ElTag: typeof import('element-plus/es')['ElTag']
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']
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"]; IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
IEpDownload: typeof import("~icons/ep/download")["default"]; IEpDownload: typeof import('~icons/ep/download')['default']
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']
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"]; IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
IEpUpload: typeof import("~icons/ep/upload")["default"]; ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"]; LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"]; MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"]; Pagination: typeof import('./../components/Pagination/index.vue')['default']
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"]; RouterLink: typeof import('vue-router')['RouterLink']
Pagination: typeof import("./../components/Pagination/index.vue")["default"]; RouterView: typeof import('vue-router')['RouterView']
RouterLink: typeof import("vue-router")["RouterLink"]; SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
RouterView: typeof import("vue-router")["RouterView"]; UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
} }
export interface ComponentCustomProperties { export interface ComponentCustomProperties {
vLoading: typeof import("element-plus/es")["ElLoadingDirective"]; vLoading: typeof import('element-plus/es')['ElLoadingDirective']
} }
} }
+19 -34
View File
@@ -24,16 +24,6 @@
:model="dataForm" :model="dataForm"
label-position="top" label-position="top"
> >
<el-form-item
:label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime"
>
<el-input
v-model="dataForm.hysteria2TrafficTime"
:placeholder="$t('config.hysteria2TrafficTime')"
clearable
/>
</el-form-item>
<el-tooltip <el-tooltip
:content="$t('config.resetTrafficCronTip')" :content="$t('config.resetTrafficCronTip')"
placement="bottom" placement="bottom"
@@ -79,6 +69,14 @@ export default {
// //
// Generic import/export конфигурации удалён: он выгружал таблицу `config` // Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами. // целиком, вместе с JWT- и peer-ключами.
//
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
// сбора метрик задан в коде админки, а traffic accounting его не читал.
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
// оператору управление, которого не существовало.
//
// Осталась ровно одна настройка, которой панель действительно владеет.
import { Select } from "@element-plus/icons-vue"; import { Select } from "@element-plus/icons-vue";
import { listConfigApi, updateConfigsApi } from "@/api/config"; import { listConfigApi, updateConfigsApi } from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types"; import { ConfigsUpdateDto } from "@/api/config/types";
@@ -88,7 +86,6 @@ const { t } = useI18n();
const dataFormRef = ref(ElForm); const dataFormRef = ref(ElForm);
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON"; const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const cronResetTraffic = [ const cronResetTraffic = [
@@ -96,24 +93,18 @@ const cronResetTraffic = [
{ key: t("config.resetTrafficWeek"), value: "@weekly" }, { key: t("config.resetTrafficWeek"), value: "@weekly" },
]; ];
const dataFormRules = { // Правил формы здесь нет намеренно.
hysteria2TrafficTime: [ //
{ // Поле — el-select с allow-create, то есть значение произвольное, и
required: true, // единственная осмысленная проверка cron-выражения — разбор его тем же
message: t("common.required"), // парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
trigger: ["change", "blur"], // вторая, приблизительная реализация на фронтенде расходилась бы с ним и
}, // давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
{ // легально и означает «автоматический сброс выключен».
pattern: /^\d+(\.\d)?$/, const dataFormRules = {};
message: t("config.invalidTrafficTime"),
trigger: ["change", "blur"],
},
],
};
const state = reactive({ const state = reactive({
dataForm: { dataForm: {
hysteria2TrafficTime: "1",
resetTrafficCron: "", resetTrafficCron: "",
}, },
}); });
@@ -124,10 +115,6 @@ const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => { dataFormRef.value.validate((valid: boolean) => {
if (valid) { if (valid) {
let configs: ConfigsUpdateDto[] = [ let configs: ConfigsUpdateDto[] = [
{
key: hysteria2TrafficTimeKey,
value: state.dataForm.hysteria2TrafficTime,
},
{ {
key: resetTrafficCronKey, key: resetTrafficCronKey,
value: state.dataForm.resetTrafficCron, value: state.dataForm.resetTrafficCron,
@@ -143,13 +130,11 @@ const submitForm = () => {
const setConfig = async () => { const setConfig = async () => {
const { data } = await listConfigApi({ const { data } = await listConfigApi({
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey], keys: [resetTrafficCronKey],
}); });
data.forEach((configVo) => { data.forEach((configVo) => {
if (configVo.key === hysteria2TrafficTimeKey) { if (configVo.key === resetTrafficCronKey) {
state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value; state.dataForm.resetTrafficCron = configVo.value;
} }
}); });
@@ -51,16 +51,6 @@
:disabled="true" :disabled="true"
> >
<el-tabs v-model="activeName" class="tabs"> <el-tabs v-model="activeName" class="tabs">
<el-tab-pane :label="$t('hysteria.extension')" name="extension">
<el-tooltip
:content="$t('hysteria.config.remark')"
placement="bottom"
>
<el-form-item label="remark" prop="remark">
<el-input v-model="configForm.remark" clearable />
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane :label="$t('hysteria.listen')" name="listen"> <el-tab-pane :label="$t('hysteria.listen')" name="listen">
<el-tooltip <el-tooltip
:content="$t('hysteria.config.listen')" :content="$t('hysteria.config.listen')"
@@ -1062,18 +1052,19 @@ import {
Hysteria2ServerConfig, Hysteria2ServerConfig,
} from "@/api/config/types"; } from "@/api/config/types";
import Outbounds from "./components/Outbounds/index.vue"; import Outbounds from "./components/Outbounds/index.vue";
import { import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
exportHysteria2ConfigApi,
getHysteria2ConfigApi,
listConfigApi,
} from "@/api/config";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { assignWith, deepCopy } from "@/utils/copy"; import { assignWith, deepCopy } from "@/utils/copy";
import { dashboardSummaryApi } from "@/api/dashboard"; import { dashboardSummaryApi } from "@/api/dashboard";
const { t } = useI18n(); const { t } = useI18n();
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK"; // Вкладки «extension» с полем remark здесь больше нет.
//
// Она показывала HYSTERIA2_CONFIG_REMARK — пустую строку в таблице `config`,
// которую clean install создавал один раз и которую не записывал никто. Имя
// профиля в клиентской ссылке теперь выводится из имени пира на сервере
// (см. service/hysteria2_api.go), поэтому настраивать здесь нечего.
const dataFormRef = ref(ElForm); const dataFormRef = ref(ElForm);
const dataFormRules = { const dataFormRules = {
@@ -1118,11 +1109,8 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]); const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({ const state = reactive({
configForm: {
remark: "",
},
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig, dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
activeName: "extension", activeName: "listen",
tlsType: "acme", tlsType: "acme",
aclType: "inline", aclType: "inline",
obfs: false, obfs: false,
@@ -1143,7 +1131,6 @@ const state = reactive({
}); });
const { const {
configForm,
activeName, activeName,
dataForm, dataForm,
tlsType, tlsType,
@@ -1183,17 +1170,6 @@ const handleExport = async () => {
}; };
const setConfig = () => { const setConfig = () => {
listConfigApi({
keys: [hysteria2Remark],
}).then((response) => {
const data = response.data;
data.forEach((configVo) => {
if (configVo.key === hysteria2Remark) {
state.configForm.remark = configVo.value;
}
});
});
getHysteria2ConfigApi().then((response) => { getHysteria2ConfigApi().then((response) => {
const data = response.data; const data = response.data;
if (data) { if (data) {
+45 -39
View File
@@ -1,67 +1,73 @@
module hy2xs-admin module hy2xs-admin
go 1.21 // Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет
// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в
// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе
// с ней в бинарь попадала соответствующая stdlib.
go 1.25.0
require ( require (
github.com/didip/tollbooth v4.0.2+incompatible github.com/didip/tollbooth v4.0.2+incompatible
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.14.0 github.com/go-playground/validator/v10 v10.30.3
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v5 v5.3.1
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil v3.21.11+incompatible github.com/shirou/gopsutil v3.21.11+incompatible
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.10.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2
github.com/spf13/cobra v1.8.1 golang.org/x/crypto v0.55.0
golang.org/x/crypto v0.18.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.9 gorm.io/gorm v1.31.2
) )
require ( require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.5.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.3.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.3.0 // indirect go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
golang.org/x/net v0.20.0 // indirect golang.org/x/arch v0.30.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect golang.org/x/time v0.15.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.23.1 // indirect modernc.org/sqlite v1.57.0 // indirect
) )
+134 -97
View File
@@ -1,11 +1,12 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -14,39 +15,42 @@ github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7z
github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY= github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -55,106 +59,139 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-44
View File
@@ -1,44 +0,0 @@
package middleware
import (
"errors"
"github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/service"
"time"
)
func InitCron() error {
loc := time.Now().Location()
c := cron.New(cron.WithLocation(loc))
_, err := c.AddFunc("@every 30s", service.CronHandleAccount)
if err != nil {
logrus.Errorf("cron add func CronHandleAccount err: %v", err)
return errors.New("cron add func CronHandleAccount err")
}
_, err = c.AddFunc("@every 10s", service.CollectMetricsSnapshot)
if err != nil {
logrus.Errorf("cron add func CollectMetricsSnapshot err: %v", err)
return errors.New("cron add func CollectMetricsSnapshot err")
}
_, err = c.AddFunc("@every 1h", service.CleanupStatsRetention)
if err != nil {
logrus.Errorf("cron add func CleanupStatsRetention err: %v", err)
return errors.New("cron add func CleanupStatsRetention err")
}
resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return err
}
if *resetTrafficCron.Value != "" {
_, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic)
if err != nil {
logrus.Errorf("cron add func CronResetTraffic err: %v", err)
}
}
go service.CleanupStatsRetention()
c.Start()
return nil
}
+22 -14
View File
@@ -8,15 +8,27 @@ package constant
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS // HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
// терминируется на внешнем слое. Панель записывала в SQLite тот же порт, // терминируется на внешнем слое. Панель записывала в SQLite тот же порт,
// который ей передали аргументом, и тут же читала его обратно. // который ей передали аргументом, и тут же читала его обратно.
// Ключей HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
// HYSTERIA2_CONFIG_REMARK здесь больше нет. Все четыре достались от предыдущего
// поколения панели и к v1 перестали чем-либо управлять:
//
// HYSTERIA2_ENABLE — жизненным циклом Hysteria владеет systemd, а
// единственным потребителем ключа была строка лога;
// HYSTERIA2_CONFIG — второй источник истины рядом с
// /etc/hysteria/config.yaml, причём читался ПЕРВЫМ;
// HYSTERIA2_TRAFFIC_TIME — настройка в панели без единого потребителя в
// рантайме: интервал сбора метрик задан в коде;
// HYSTERIA2_CONFIG_REMARK — пустая read-only строка, которую никто не писал.
//
// Пустой ключ, который нельзя изменить и который ни на что не влияет, — это не
// «задел на будущее», а состояние, которое приходится объяснять в документации
// и защищать в allowlist. Настоящую замену получил только remark: имя профиля
// в share URI теперь выводится из имени пира (см. service/hysteria2_api.go).
const ( const (
JwtSecret = "JWT_SECRET" JwtSecret = "JWT_SECRET"
PeerSecretKey = "PEER_SECRET_KEY" PeerSecretKey = "PEER_SECRET_KEY"
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET" Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
Hysteria2Enable = "HYSTERIA2_ENABLE"
Hysteria2Config = "HYSTERIA2_CONFIG"
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
ResetTrafficCron = "RESET_TRAFFIC_CRON" ResetTrafficCron = "RESET_TRAFFIC_CRON"
) )
@@ -40,20 +52,18 @@ const (
var ( var (
// publicReadableConfigKeys — настройки, которые панель показывает оператору. // publicReadableConfigKeys — настройки, которые панель показывает оператору.
publicReadableConfigKeys = map[string]struct{}{ publicReadableConfigKeys = map[string]struct{}{
Hysteria2TrafficTime: {}, ResetTrafficCron: {},
ResetTrafficCron: {},
Hysteria2ConfigRemark: {},
} }
// publicWritableConfigKeys — подмножество читаемых, которые оператор // publicWritableConfigKeys — подмножество читаемых, которые оператор
// действительно редактирует в интерфейсе. // действительно редактирует в интерфейсе.
// //
// HYSTERIA2_CONFIG_REMARK сюда не входит: панель его только отображает. // После очистки мёртвых ключей оба списка совпали: единственная настройка,
// Право записи, которым никто не пользуется, — это attack surface без // которой владеет панель, — расписание сброса счётчиков трафика. Списки
// пользователя. // намеренно не схлопнуты в один: читаемое и записываемое — разные права, и
// первый же ключ «показываем, но менять нельзя» вернёт разницу.
publicWritableConfigKeys = map[string]struct{}{ publicWritableConfigKeys = map[string]struct{}{
Hysteria2TrafficTime: {}, ResetTrafficCron: {},
ResetTrafficCron: {},
} }
) )
@@ -89,8 +99,6 @@ func InternalConfigKeys() []string {
PeerSecretKey, PeerSecretKey,
PeerSecretEncryptionKey, PeerSecretEncryptionKey,
Hysteria2TrafficStatsSecret, Hysteria2TrafficStatsSecret,
Hysteria2Enable,
Hysteria2Config,
} }
} }
+25 -12
View File
@@ -34,29 +34,42 @@ func TestSecretKeysAreListedAsInternal(t *testing.T) {
} }
func TestOperatorSettingsRemainAvailable(t *testing.T) { func TestOperatorSettingsRemainAvailable(t *testing.T) {
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron, Hysteria2ConfigRemark} { if !IsPublicReadableConfigKey(ResetTrafficCron) {
if !IsPublicReadableConfigKey(key) { t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron)
t.Errorf("пользовательская настройка %s недоступна на чтение", key)
}
} }
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron} { if !IsPublicWritableConfigKey(ResetTrafficCron) {
if !IsPublicWritableConfigKey(key) { t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron)
t.Errorf("пользовательская настройка %s недоступна на запись", key) }
}
// Ключи, удалённые как мёртвое состояние, не должны вернуться в API «по
// инерции» вместе с восстановлением константы. Проверка идёт по строковым
// литералам: соответствующих констант в коде уже нет, и появиться они не
// должны.
func TestRemovedKeysStayClosed(t *testing.T) {
for _, key := range []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
} {
if IsPublicReadableConfigKey(key) {
t.Errorf("удалённый ключ %s снова доступен на чтение", key)
}
if IsPublicWritableConfigKey(key) {
t.Errorf("удалённый ключ %s снова доступен на запись", key)
} }
} }
} }
// Право записи без потребителя — это attack surface без пользователя. // Право записи без права чтения означает настройку, которую оператор может
// HYSTERIA2_CONFIG_REMARK панель только показывает. // изменить, но не может увидеть.
func TestWritableIsSubsetOfReadable(t *testing.T) { func TestWritableIsSubsetOfReadable(t *testing.T) {
for _, key := range PublicWritableConfigKeys() { for _, key := range PublicWritableConfigKeys() {
if !IsPublicReadableConfigKey(key) { if !IsPublicReadableConfigKey(key) {
t.Errorf("ключ %s можно записать, но нельзя прочитать", key) t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
} }
} }
if IsPublicWritableConfigKey(Hysteria2ConfigRemark) {
t.Error("HYSTERIA2_CONFIG_REMARK не редактируется в панели и не должен быть записываемым")
}
} }
func TestUnknownKeyIsClosedByDefault(t *testing.T) { func TestUnknownKeyIsClosedByDefault(t *testing.T) {
+20 -16
View File
@@ -30,16 +30,9 @@ func Login(username string, plainPassword string) (string, bool, error) {
if err != nil { if err != nil {
return "", false, err return "", false, err
} }
verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash) if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
if !verified {
return "", false, errors.New(constant.WrongPassword) return "", false, errors.New(constant.WrongPassword)
} }
if legacy {
hash, hashErr := util.HashPassword(plainPassword)
if hashErr == nil {
_ = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{"password_hash": hash})
}
}
tokenVersion := int64(1) tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 { if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion tokenVersion = *admin.TokenVersion
@@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil { if tokenErr != nil {
return "", false, tokenErr return "", false, tokenErr
} }
requirePasswordChange := legacy // Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
// с поддержкой самого формата: единственный источник этого требования —
// флаг force_password_change, который ставит установка.
requirePasswordChange := false
if admin.ForcePasswordChange != nil { if admin.ForcePasswordChange != nil {
requirePasswordChange = *admin.ForcePasswordChange != 0 requirePasswordChange = *admin.ForcePasswordChange != 0
} }
return token, requirePasswordChange, nil return token, requirePasswordChange, nil
} }
// GetAdminInfo читает учётные данные ТОЛЬКО из контекста запроса.
//
// Резервной ветки «если в контексте пусто — разобрать токен самостоятельно»
// здесь больше нет. Она была недостижима: все маршруты, ведущие сюда, стоят за
// middleware.JWTHandler, который разбирает токен, сверяет token_version и
// кладёт claims в контекст. При этом ветка несла собственный разбор заголовка
// Authorization через service.GetToken, где strings.SplitN(...)[1] паниковал
// на заголовке без пробела.
//
// Хуже недостижимости было бы её оживление: этот путь не проверял ни статус
// учётной записи, ни версию токена, то есть отозванный токен прошёл бы здесь,
// но не прошёл бы через middleware. Второй способ аутентификации, слабее
// первого, — не запасной вариант, а обход.
func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) { func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) {
claims, ok := adminClaimsFromContext(c) claims, ok := adminClaimsFromContext(c)
if !ok { if !ok {
myClaims, err := ParseToken(GetToken(c)) return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
if err != nil {
return vo.AdminInfoVo{}, err
}
claims = myClaims.Admin
} }
admin, err := dao.GetAdminUser("id = ?", claims.Id) admin, err := dao.GetAdminUser("id = ?", claims.Id)
if err != nil { if err != nil {
@@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
if admin.PasswordHash == nil { if admin.PasswordHash == nil {
return errors.New(constant.SysError) return errors.New(constant.SysError)
} }
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash) if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
if !verified {
return errors.New(constant.WrongPassword) return errors.New(constant.WrongPassword)
} }
hash, hashErr := util.HashPassword(newPassword) hash, hashErr := util.HashPassword(newPassword)
+63 -14
View File
@@ -15,10 +15,53 @@ import (
"strings" "strings"
) )
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
type ConfigUpdate struct {
Key string
Value string
}
func UpdateConfig(key string, value string) error { func UpdateConfig(key string, value string) error {
return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value}) return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
} }
// UpdateConfigs применяет партию настроек целиком или не применяет вовсе.
//
// Проверка значений в неё не входит намеренно: партия обязана быть проверена
// ПОЛНОСТЬЮ до открытия транзакции. Транзакция, внутри которой ещё может
// возникнуть отказ по содержимому, — это откат вместо отказа, то есть лишняя
// работа с базой на каждом неверно заполненном поле формы.
func UpdateConfigs(updates []ConfigUpdate) error {
if len(updates) == 0 {
return nil
}
return dao.WithConfigTx(func(tx dao.ConfigTx) error {
for _, item := range updates {
if err := tx.UpdateConfig([]string{item.Key}, map[string]interface{}{"value": item.Value}); err != nil {
return err
}
}
return nil
})
}
// ValidateConfigValue проверяет ЗНАЧЕНИЕ настройки до записи в базу.
//
// Раньше такой проверки не было вовсе: DTO ограничивал длину 128 символами, а
// поле расписания в панели — el-select с allow-create, то есть строка
// произвольная. Невалидное cron-выражение сохранялось, API отвечал успехом, а
// планировщик при следующем старте просто логировал ошибку и продолжал без
// джобы. Для оператора это выглядело как «настройка принята, сброс трафика
// молча перестал работать».
func ValidateConfigValue(key string, value string) error {
switch key {
case constant.ResetTrafficCron:
return ValidateResetTrafficCron(value)
default:
return nil
}
}
func GetConfig(key string) (entity.Config, error) { func GetConfig(key string) (entity.Config, error) {
return dao.GetConfig("key = ?", key) return dao.GetConfig("key = ?", key)
} }
@@ -27,23 +70,26 @@ func ListConfig(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key in ?", keys) return dao.ListConfig("key in ?", keys)
} }
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
//
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
// install создавал его пустым, и пустым он оставался навсегда.
//
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
// продолжала работать по файлу. То есть ключ был готовым вторым источником
// истины — ровно тем, что проект последовательно убирает.
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) { func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
var serverConfig bo.Hysteria2ServerConfig var serverConfig bo.Hysteria2ServerConfig
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil { if err != nil {
return serverConfig, err return serverConfig, err
} }
if config.Value == nil || strings.TrimSpace(*config.Value) == "" { if err = yaml.Unmarshal(content, &serverConfig); err != nil {
content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
if readErr != nil {
return serverConfig, readErr
}
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
return serverConfig, err
}
return serverConfig, nil
}
if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil {
return serverConfig, err return serverConfig, err
} }
return serverConfig, nil return serverConfig, nil
@@ -54,15 +100,18 @@ func GetHysteria2ApiPort() (int64, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
// Сообщение передаётся аргументом, а не форматной строкой: значение
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
// запись журнала в мусор вида `%!s(MISSING)`.
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil { if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil" errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
logrus.Errorf(errMsg) logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg) return 0, errors.New(errMsg)
} }
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen) apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
if err != nil { if err != nil {
errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err) errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
logrus.Errorf(errMsg) logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg) return 0, errors.New(errMsg)
} }
return apiPort, nil return apiPort, nil
+244
View File
@@ -0,0 +1,244 @@
package service
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Планировщик принадлежит ПРОЦЕССУ, а не HTTP-серверу.
//
// Что было. InitCron жил в middleware, вызывался из runServer и на каждом
// вызове создавал новый cron.New(). Ссылка на планировщик никуда не
// сохранялась, а releaseResource() закрывал только SQLite и Hysteria —
// cron.Stop() не вызывался нигде. При этом смена RESET_TRAFFIC_CRON делала
// StopServer(), из-за чего runServer возвращался, а точка входа в cmd.go
// крутила его в `for {}` и запускала всё заново.
//
// Итог: каждая смена расписания добавляла ЦЕЛЫЙ дублирующий набор джоб, а
// старое расписание сброса трафика продолжало работать. После двух правок
// «@monthly → @weekly → @monthly» на процессе висели три планировщика,
// утроенный CollectMetricsSnapshot и три разных расписания сброса
// одновременно. Плюс окно, в котором джобы старого планировщика били в уже
// закрытое соединение SQLite: releaseResource() отрабатывал раньше, чем
// следующий runServer успевал открыть базу.
//
// Правильная граница: фиксированные джобы регистрируются один раз за жизнь
// процесса, а расписание сброса трафика перепланируется на месте по своему
// EntryID. HTTP-сервер к смене настройки отношения не имеет вообще.
// cronShutdownTimeout ограничивает ожидание уже запущенных джоб при остановке.
// systemd по умолчанию даёт юниту 90 секунд, так что запас есть.
const cronShutdownTimeout = 10 * time.Second
var (
cronMu sync.Mutex
cronScheduler *cron.Cron
resetTrafficEntryID cron.EntryID
// cronOwnedJobs учитывает джобы, запущенные ВНЕ расписания.
//
// Стартовая уборка статистики намеренно выполняется в фоне: на большой базе
// она заметно долгая, и держать на ней запуск сервиса незачем. Но
// cron.Stop() ждёт только то, что запустил сам планировщик, поэтому
// необслуженная горутина переживала закрытие SQLite — ровно та же болезнь,
// от которой лечится весь этот файл, только меньшего масштаба.
cronOwnedJobs sync.WaitGroup
)
// ValidateResetTrafficCron проверяет выражение ТЕМ ЖЕ парсером, которым его
// потом будет разбирать runtime.
//
// cron.New() без опций собирает parser из Minute|Hour|Dom|Month|Dow|Descriptor,
// и ровно его же использует cron.ParseStandard. Поэтому «валидно на входе API»
// и «планируется в рантайме» здесь не могут разойтись — а разойтись они могли
// бы, если бы валидация была написана собственной регуляркой.
//
// Пустая строка — легальное значение и означает «автоматический сброс
// выключен»: в панели поле clearable, и оператор имеет право его очистить.
func ValidateResetTrafficCron(expression string) error {
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
if _, err := cron.ParseStandard(trimmed); err != nil {
return fmt.Errorf("%s: невалидное cron-выражение %q: %v", constant.ResetTrafficCron, expression, err)
}
return nil
}
// InitCron поднимает единственный планировщик процесса.
func InitCron() error {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler != nil {
return errors.New("cron scheduler is already running")
}
c := cron.New(cron.WithLocation(time.Now().Location()))
fixedJobs := []struct {
name string
spec string
job func()
}{
{"CronHandleAccount", "@every 30s", CronHandleAccount},
{"CollectMetricsSnapshot", "@every 10s", CollectMetricsSnapshot},
{"CleanupStatsRetention", "@every 1h", CleanupStatsRetention},
}
for _, fixed := range fixedJobs {
if _, err := c.AddFunc(fixed.spec, fixed.job); err != nil {
logrus.Errorf("cron add func %s err: %v", fixed.name, err)
return fmt.Errorf("cron add func %s err", fixed.name)
}
}
expression, err := storedResetTrafficCron()
if err != nil {
return err
}
if expression != "" {
id, addErr := c.AddFunc(expression, CronResetTraffic)
if addErr != nil {
// Старт НЕ прерывается, и это осознанное решение.
//
// Панель отдаёт не только операторский UI: на ней же висит
// /internal/hysteria/auth, куда Hysteria ходит при каждом
// подключении пира. Отказ старта из-за испорченной строки
// расписания положил бы подключения пользователей — цена
// несопоставима с отключённым плановым сбросом счётчиков.
//
// Молчаливой деградации при этом нет: запись через API теперь
// валидируется тем же парсером, поэтому попасть сюда можно только
// правкой базы в обход продукта, и об этом пишется ERROR.
logrus.Errorf(
"cron: сохранённое %s=%q невалидно (%v); плановый сброс трафика выключен до исправления настройки",
constant.ResetTrafficCron, expression, addErr,
)
} else {
resetTrafficEntryID = id
}
}
cronOwnedJobs.Add(1)
go func() {
defer cronOwnedJobs.Done()
CleanupStatsRetention()
}()
c.Start()
cronScheduler = c
return nil
}
// RescheduleResetTraffic переносит джобу сброса трафика на новое расписание.
//
// HTTP-сервер здесь не участвует. Раньше единственным способом применить новое
// расписание был перезапуск процесса через StopServer(), и именно он и плодил
// планировщики.
func RescheduleResetTraffic(expression string) error {
if err := ValidateResetTrafficCron(expression); err != nil {
return err
}
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return errors.New("cron scheduler is not running")
}
// Старая запись снимается всегда, даже если новая не будет добавлена:
// пустое выражение означает «сброс выключен», а не «оставить как было».
if resetTrafficEntryID != 0 {
cronScheduler.Remove(resetTrafficEntryID)
resetTrafficEntryID = 0
}
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
// Ошибка здесь уже невозможна: выражение прошло тот же парсер выше.
// Проверка остаётся, чтобы расхождение двух парсеров не превратилось в
// молча пропавшую джобу.
id, err := cronScheduler.AddFunc(trimmed, CronResetTraffic)
if err != nil {
return fmt.Errorf("%s: не удалось запланировать %q: %v", constant.ResetTrafficCron, expression, err)
}
resetTrafficEntryID = id
return nil
}
// StopCron останавливает планировщик и дожидается уже запущенных джоб.
//
// Без этого джобы продолжали работать после закрытия SQLite: каждая из них
// ходит в базу, и остановка в обратном порядке (сначала планировщик, потом
// соединение) — часть контракта завершения процесса.
func StopCron() {
cronMu.Lock()
scheduler := cronScheduler
cronScheduler = nil
resetTrafficEntryID = 0
cronMu.Unlock()
if scheduler == nil {
return
}
// Дожидаемся обеих групп: и джоб, запущенных планировщиком, и фоновых,
// которые он не видит. Незавершённая джоба означает работу с базой, которую
// releaseResource закроет сразу после возврата отсюда.
drained := make(chan struct{})
go func() {
<-scheduler.Stop().Done()
cronOwnedJobs.Wait()
close(drained)
}()
select {
case <-drained:
case <-time.After(cronShutdownTimeout):
logrus.Warnf("cron: запущенные джобы не завершились за %s, продолжаем остановку", cronShutdownTimeout)
}
}
// ResetTrafficScheduled сообщает, запланирован ли сейчас сброс трафика.
// Существует ради тестов: иначе проверить, что старая запись действительно
// снята, а не просто добавлена рядом, можно было бы только по времени.
func ResetTrafficScheduled() bool {
cronMu.Lock()
defer cronMu.Unlock()
return resetTrafficEntryID != 0
}
// CronEntryCount возвращает число активных записей планировщика.
// Тоже ради тестов: накопление джоб — это именно рост этого числа.
func CronEntryCount() int {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return 0
}
return len(cronScheduler.Entries())
}
func storedResetTrafficCron() (string, error) {
config, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return "", err
}
if config.Value == nil {
return "", nil
}
return strings.TrimSpace(*config.Value), nil
}
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Число фиксированных джоб планировщика: CronHandleAccount,
// CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно —
// тест обязан заметить и появление новой джобы, и потерю существующей.
const fixedCronJobCount = 3
func startTestCron(t *testing.T, resetTrafficExpression string) {
t.Helper()
newTestDB(t)
if err := dao.UpdateConfig(
[]string{constant.ResetTrafficCron},
map[string]interface{}{"value": resetTrafficExpression},
); err != nil {
t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err)
}
if err := InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
t.Cleanup(StopCron)
}
// Главная регрессия P0: смена расписания не имеет права добавлять джобы.
//
// Раньше применение новой настройки шло через StopServer() и повторный
// runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его
// не сохранял. Старый планировщик никто не останавливал, поэтому после каждой
// правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое
// расписание сброса трафика продолжало срабатывать.
func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) {
startTestCron(t, "@monthly")
initial := CronEntryCount()
if initial != fixedCronJobCount+1 {
t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial)
}
for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} {
if err := RescheduleResetTraffic(expression); err != nil {
t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err)
}
if count := CronEntryCount(); count != initial {
t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial)
}
}
}
// Пустое выражение означает «автоматический сброс выключен», а не «оставить
// как было»: поле в панели clearable, и оператор имеет право его очистить.
func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) {
startTestCron(t, "@monthly")
if !ResetTrafficScheduled() {
t.Fatal("после старта с непустым расписанием джоба сброса не запланирована")
}
if err := RescheduleResetTraffic(" "); err != nil {
t.Fatalf("RescheduleResetTraffic(пусто): %v", err)
}
if ResetTrafficScheduled() {
t.Fatal("пустое выражение не сняло джобу сброса трафика")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Включение обратно должно работать без перезапуска процесса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("повторное включение: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("джоба сброса не вернулась после повторного включения")
}
}
// Невалидное выражение не имеет права ни изменить планировщик, ни пройти
// молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо
// исчезал.
func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) {
startTestCron(t, "@monthly")
before := CronEntryCount()
if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil {
t.Fatal("невалидное выражение принято")
}
if after := CronEntryCount(); after != before {
t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after)
}
if !ResetTrafficScheduled() {
t.Fatal("отклонённое выражение сняло действующую джобу")
}
}
// Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор
// проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не
// умеет, — отклоняться до записи в базу.
func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) {
valid := []string{
"",
" ",
"@monthly",
"@weekly",
"@daily",
"@hourly",
"@every 1h30m",
"0 0 * * *",
"*/15 * * * *",
"0 3 1 * 1",
}
for _, expression := range valid {
if err := ValidateResetTrafficCron(expression); err != nil {
t.Errorf("валидное выражение %q отклонено: %v", expression, err)
}
}
invalid := []string{
"какая-нибудь ерунда",
"@yearly extra",
"* * * *",
"* * * * * *",
"99 * * * *",
"@every",
"@every банан",
"- - - - -",
}
for _, expression := range invalid {
if err := ValidateResetTrafficCron(expression); err == nil {
t.Errorf("невалидное выражение %q принято", expression)
}
}
}
// Невалидное значение в базе не имеет права уронить старт: на панели висит
// /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы
// подключения пользователей.
func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) {
startTestCron(t, "совершенно невалидная строка")
if ResetTrafficScheduled() {
t.Fatal("невалидное выражение из базы было запланировано")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Оператор обязан иметь возможность починить настройку через API, без
// перезапуска сервиса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("исправление расписания: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("исправленное расписание не применилось")
}
}
// Планировщик принадлежит процессу: второй InitCron без остановки первого —
// это ровно тот сценарий, который и создавал дубликаты.
func TestInitCronRefusesSecondScheduler(t *testing.T) {
startTestCron(t, "@monthly")
if err := InitCron(); err == nil {
t.Fatal("второй планировщик поднялся поверх работающего")
}
if count := CronEntryCount(); count != fixedCronJobCount+1 {
t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count)
}
}
func TestStopCronIsIdempotent(t *testing.T) {
startTestCron(t, "@monthly")
StopCron()
if count := CronEntryCount(); count != 0 {
t.Fatalf("после остановки осталось %d записей", count)
}
if ResetTrafficScheduled() {
t.Fatal("после остановки джоба сброса числится запланированной")
}
// Второй вызов не должен паниковать: StopCron стоит в defer runServer и
// может быть вызван повторно на пути обработки ошибки.
StopCron()
if err := RescheduleResetTraffic("@weekly"); err == nil {
t.Fatal("перепланирование на остановленном планировщике не отклонено")
}
}
+9 -12
View File
@@ -3,9 +3,6 @@ package service
import ( import (
"errors" "errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/util" "hy2xs-admin/util"
) )
@@ -13,19 +10,19 @@ import (
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и // Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
// смены версии здесь нет намеренно, а не «временно отключены». // смены версии здесь нет намеренно, а не «временно отключены».
// InitHysteria2 проверяет единственное, что панели действительно нужно от
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
// у которого спрашивает состояние Hysteria2IsRunning.
//
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
// в которой панель сама запускала и останавливала Hysteria: clean install
// создавал его со значением "0", записать "1" было некому, а вся ветка
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
// выглядит выключателем, хуже отсутствующей.
func InitHysteria2() error { func InitHysteria2() error {
if !util.Exists(util.GetHysteria2BinPath()) { if !util.Exists(util.GetHysteria2BinPath()) {
return errors.New("systemd-managed hysteria binary not found") return errors.New("systemd-managed hysteria binary not found")
} }
config, err := dao.GetConfig("key = ?", constant.Hysteria2Enable)
if err != nil {
return err
}
if *config.Value == "1" {
logrus.Infof("hysteria2 lifecycle is managed by systemd in HY2XS production package")
}
return nil return nil
} }
+22 -8
View File
@@ -148,15 +148,8 @@ func Hysteria2Url(accountId int64) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", err
}
remark := "" remark := shareRemark(peer.Name, hostname)
if hysteria2ConfigRemark.Value != nil {
remark = *hysteria2ConfigRemark.Value
}
obfs := hysteria2Config.ObfsShare() obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname) sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
@@ -172,6 +165,27 @@ func Hysteria2Url(accountId int64) (string, error) {
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
} }
// shareRemark даёт имя профиля, которое клиент показывает в списке серверов.
//
// Раньше оно бралось из HYSTERIA2_CONFIG_REMARK — пустой строки в таблице
// `config`, которую clean install создавал один раз и которую никто никогда не
// записывал. То есть fragment у ссылки отсутствовал всегда, и все выданные
// ссылки выглядели в клиенте одинаково.
//
// Теперь значение выводится детерминированно из имени пира: у панели с
// несколькими пирами это ровно то различие, которое пользователю и нужно
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
// публичный хост нужен для пира без имени — база это допускает (name имеет
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
func shareRemark(peerName *string, hostname string) string {
if peerName != nil {
if name := strings.TrimSpace(*peerName); name != "" {
return name
}
}
return hostname
}
// isShareableObfsType перечисляет типы обфускации, которые официальная // isShareableObfsType перечисляет типы обфускации, которые официальная
// URI-схема Hysteria умеет передавать клиенту. // URI-схема Hysteria умеет передавать клиенту.
func isShareableObfsType(obfsType string) bool { func isShareableObfsType(obfsType string) bool {
+7 -10
View File
@@ -8,7 +8,6 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
) )
@@ -21,19 +20,17 @@ const RedactedPlaceholder = "<redacted>"
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель // Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
// отражает известные HY2XS поля и используется для отображения, а сырой YAML // отражает известные HY2XS поля и используется для отображения, а сырой YAML
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает. // нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
//
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
// Здесь у неё было дополнительное следствие — выгрузка «фактического
// серверного конфига» могла отдать оператору документ, по которому Hysteria
// никогда не работала.
func GetRawHysteria2Config() (string, error) { func GetRawHysteria2Config() (string, error) {
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil { if err != nil {
return "", err return "", err
} }
if config.Value != nil && strings.TrimSpace(*config.Value) != "" {
return *config.Value, nil
}
content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
if readErr != nil {
return "", readErr
}
return string(content), nil return string(content), nil
} }
+18
View File
@@ -4,8 +4,10 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/util" "hy2xs-admin/util"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -58,6 +60,22 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
return result, len(result), nil return result, len(result), nil
} }
// ExportAdminLog отдаёт журнал самой админки, вырезав из него секреты.
//
// Раньше этот файл выгружался как есть, через c.File: чужой журнал продукт
// санитизировал, а собственный — нет. Обоснования у асимметрии не было, а цена
// известна поимённо: пока bootstrap-пароль администратора печатался в лог
// warning'ом, обычная кнопка «выгрузить журнал» отдавала его открытым текстом.
// Сам warning убран (см. dao/sqlite.go), но защита от следующего такого места
// должна стоять и на выходе.
func ExportAdminLog() (string, error) {
content, err := os.ReadFile(constant.SystemLogPath)
if err != nil {
return "", err
}
return SanitizeLogText(string(content)), nil
}
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору. // ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
// //
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные // Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
+87 -36
View File
@@ -2,59 +2,110 @@ package service
import ( import (
"errors" "errors"
"github.com/gin-gonic/gin" "strings"
"github.com/golang-jwt/jwt" "time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"strings"
"time"
) )
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
//
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
// кто может обратиться к панели, попадает в него до всякой проверки прав.
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
//
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
const TokenExpireDuration = time.Hour * 24 const TokenExpireDuration = time.Hour * 24
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
// не должен разбираться как наш даже при совпадении ключа.
const tokenIssuer = "hy2xs-admin"
type MyClaims struct { type MyClaims struct {
Admin bo.AccountBo `json:"admin"` Admin bo.AccountBo `json:"admin"`
jwt.StandardClaims jwt.RegisteredClaims
} }
func GenToken(accountBo bo.AccountBo) (string, error) { func jwtSecret() ([]byte, error) {
c := MyClaims{
Admin: accountBo,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
Issuer: "hy2xs-admin",
},
}
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return "", errors.New(constant.SysError)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return token.SignedString([]byte(*config.Value))
}
func ParseToken(tokenString string) (*MyClaims, error) {
config, err := dao.GetConfig("key = ?", constant.JwtSecret) config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil { if err != nil {
return nil, errors.New(constant.SysError) return nil, errors.New(constant.SysError)
} }
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) { if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
return []byte(*config.Value), nil // Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
}) // Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
if err != nil { // молча превращался в ключ нулевой длины.
return nil, errors.New(constant.IllegalTokenError) return nil, errors.New(constant.SysError)
} }
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid { return []byte(*config.Value), nil
return claims, nil
}
return nil, errors.New(constant.TokenExpiredError)
} }
func GetToken(c *gin.Context) string { func GenToken(accountBo bo.AccountBo) (string, error) {
tokenStr := c.Request.Header.Get("Authorization") secret, err := jwtSecret()
if tokenStr == "" { if err != nil {
return "" return "", err
} }
return strings.SplitN(tokenStr, " ", 2)[1]
now := time.Now()
claims := MyClaims{
Admin: accountBo,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
} }
func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(
tokenString,
&MyClaims{},
func(*jwt.Token) (interface{}, error) { return secret, nil },
// Допустимый алгоритм ровно один. Без этого ограничения набор
// алгоритмов задаёт сам токен, то есть недоверенный вход.
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(tokenIssuer),
// Токен без срока жизни не должен считаться валидным бессрочным.
jwt.WithExpirationRequired(),
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError)
}
return nil, errors.New(constant.IllegalTokenError)
}
claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError)
}
return claims, nil
}
// Функции GetToken здесь больше нет.
//
// Она доставала токен из заголовка Authorization вторым, независимым от
// middleware.JWTHandler способом — и делала это через
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
// пробела: `Authorization: abc` давал index out of range. Единственный
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.
+204
View File
@@ -0,0 +1,204 @@
package service
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
)
func newJwtTestDB(t *testing.T) []byte {
t.Helper()
newTestDB(t)
secret := "test-jwt-secret-not-used-anywhere-else-0123456789"
if err := dao.UpsertConfigValue(constant.JwtSecret, secret); err != nil {
t.Fatalf("не удалось записать %s: %v", constant.JwtSecret, err)
}
return []byte(secret)
}
func testAccount() bo.AccountBo {
return bo.AccountBo{Id: 1, Username: "hy2xsadmin", Roles: []string{"admin"}, TokenVersion: 1}
}
func TestGenAndParseTokenRoundTrip(t *testing.T) {
newJwtTestDB(t)
token, err := GenToken(testAccount())
if err != nil {
t.Fatalf("GenToken: %v", err)
}
claims, err := ParseToken(token)
if err != nil {
t.Fatalf("ParseToken: %v", err)
}
if claims.Admin.Id != 1 || claims.Admin.Username != "hy2xsadmin" {
t.Fatalf("claims не совпадают: %+v", claims.Admin)
}
if claims.Admin.TokenVersion != 1 {
t.Fatalf("token_version потерян: %d", claims.Admin.TokenVersion)
}
if claims.ExpiresAt == nil {
t.Fatal("токен выписан без срока жизни")
}
}
// Алгоритм подписи задаётся сервером, а не токеном.
//
// Прежний keyfunc возвращал ключ, не глядя на token.Method: набор допустимых
// алгоритмов фактически определял недоверенный вход. Здесь проверяется, что
// токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется.
func TestParseTokenRejectsOtherSigningMethod(t *testing.T) {
secret := newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен, подписанный HS512, принят при разрешённом только HS256")
}
}
// Токен без exp не должен считаться бессрочно валидным.
func TestParseTokenRejectsTokenWithoutExpiration(t *testing.T) {
secret := newJwtTestDB(t)
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен без exp принят")
}
}
func TestParseTokenRejectsExpiredToken(t *testing.T) {
secret := newJwtTestDB(t)
past := time.Now().Add(-2 * time.Hour)
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(past.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(past),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
_, err = ParseToken(signed)
if err == nil {
t.Fatal("истёкший токен принят")
}
if err.Error() != constant.TokenExpiredError {
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err)
}
}
// Токен, выписанный не нами, не должен разбираться как наш даже при совпадении
// ключа: issuer входит в проверку.
func TestParseTokenRejectsForeignIssuer(t *testing.T) {
secret := newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "some-other-service",
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен с чужим issuer принят")
}
}
func TestParseTokenRejectsWrongSecret(t *testing.T) {
newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("совершенно другой ключ подписи"))
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен, подписанный чужим ключом, принят")
}
}
func TestParseTokenRejectsGarbage(t *testing.T) {
newJwtTestDB(t)
for _, value := range []string{
"",
"not-a-token",
"a.b.c",
strings.Repeat("A", 500),
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6eyJpZCI6MX19.",
} {
if _, err := ParseToken(value); err == nil {
t.Errorf("мусорный токен %q принят", value)
}
}
}
// Пустой JWT_SECRET — повреждённое состояние, а не «ключ нулевой длины».
// Раньше значение разыменовывалось без проверки, и подпись выполнялась пустым
// ключом: такой токен подделывает кто угодно.
func TestGenTokenRefusesEmptySecret(t *testing.T) {
newTestDB(t)
if err := dao.UpsertConfigValue(constant.JwtSecret, ""); err != nil {
t.Fatalf("не удалось очистить %s: %v", constant.JwtSecret, err)
}
if _, err := GenToken(testAccount()); err == nil {
t.Fatal("токен подписан пустым ключом")
}
if _, err := ParseToken("whatever"); err == nil {
t.Fatal("разбор токена с пустым ключом не отклонён")
}
}
+75
View File
@@ -46,10 +46,40 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
return result, total, nil return result, total, nil
} }
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
//
// Что было. ReservedBootstrapPeerName проверялся исключительно в
// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть
// ровно то, ради чего guard существует, делалось через интерфейс: секрет
// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret,
// откуда его читает проверка machine-auth оркестратора, и смена секрета через
// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две,
// а замок висел на одной.
//
// Границы намеренно разные для разных операций:
//
// идентичность и секрет — запрещены: они и есть то, что дублируется в файле;
// удаление и отключение — разрешены: это осознанные действия оператора,
// результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
)
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" { if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError) return vo.PeerVo{}, errors.New(constant.InvalidError)
} }
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
if ExistPeerName(*peerDto.Name, 0) { if ExistPeerName(*peerDto.Name, 0) {
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name)) return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
} }
@@ -94,6 +124,10 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
} }
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
return err
}
updates := map[string]interface{}{} updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" { if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name updates["name"] = *peerDto.Name
@@ -128,6 +162,47 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
return dao.UpdatePeer([]int64{id}, updates) return dao.UpdatePeer([]int64{id}, updates)
} }
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error {
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
// существует.
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
}
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return nil
}
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" &&
strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName {
return errBootstrapPeerIdentity
}
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
return errBootstrapPeerIdentity
}
return nil
}
// DeletePeer удаляет пира, включая bootstrap-пира установщика.
//
// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный
// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
// списке.
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) { func GetPeerVo(id int64) (vo.PeerVo, error) {
+108
View File
@@ -0,0 +1,108 @@
package service
import (
"strings"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/dto"
)
func strPtr(v string) *string { return &v }
// Пир установщика защищён во всех путях записи, а не только в импорте.
//
// Раньше проверка ReservedBootstrapPeerName стояла исключительно в
// applyPeerImportEntry: секрет bootstrap-admin-peer продублирован в
// /etc/hy2xs/bootstrap-admin.secret, и смена его через обычную форму панели
// молча рассинхронизировала бы файл с базой.
func TestUpdatePeerRefusesToResecretBootstrapPeer(t *testing.T) {
newTestDB(t)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
before := snapshotPeers(t)[ReservedBootstrapPeerName]
err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("attacker-chosen-secret")})
if err == nil {
t.Fatal("смена секрета пира установщика не отклонена")
}
if !strings.Contains(err.Error(), ReservedBootstrapPeerName) {
t.Errorf("отказ не называет пира: %v", err)
}
after := snapshotPeers(t)[ReservedBootstrapPeerName]
if after.SecretDigest == nil || before.SecretDigest == nil || *after.SecretDigest != *before.SecretDigest {
t.Fatal("секрет пира установщика изменился несмотря на отказ")
}
}
func TestUpdatePeerRefusesToRenameBootstrapPeer(t *testing.T) {
newTestDB(t)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
if err := UpdatePeer(id, dto.PeerUpdateDto{Name: strPtr("renamed-peer")}); err == nil {
t.Fatal("переименование пира установщика не отклонено")
}
if _, ok := snapshotPeers(t)[ReservedBootstrapPeerName]; !ok {
t.Fatal("пир установщика переименован несмотря на отказ")
}
}
// Зарезервированное имя нельзя занять чужому пиру. UNIQUE(name) закрывает
// этот путь только пока bootstrap-пир существует, а его разрешено удалять.
func TestUpdatePeerRefusesToTakeReservedName(t *testing.T) {
newTestDB(t)
id := seedPeer(t, "ordinary-peer", "ordinary-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Name: strPtr(ReservedBootstrapPeerName)}); err == nil {
t.Fatal("переименование обычного пира в зарезервированное имя не отклонено")
}
peers := snapshotPeers(t)
if _, ok := peers["ordinary-peer"]; !ok {
t.Fatal("обычный пир переименован несмотря на отказ")
}
}
func TestCreatePeerRefusesReservedName(t *testing.T) {
newTestDB(t)
name := ReservedBootstrapPeerName
quota := int64(-1)
if _, err := CreatePeer(dto.PeerSaveDto{Name: &name, QuotaBytes: &quota}); err == nil {
t.Fatal("создание пира с зарезервированным именем не отклонено")
}
}
// Изменения, не затрагивающие идентичность и секрет, обязаны проходить: иначе
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
newTestDB(t)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
disabled := int64(1)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &disabled}); err != nil {
t.Fatalf("отключение пира установщика отклонено: %v", err)
}
peer := snapshotPeers(t)[ReservedBootstrapPeerName]
if peer.Disabled == nil || *peer.Disabled != 1 {
t.Fatal("отключение не применилось")
}
}
// Удаление разрешено намеренно: bootstrap-пир после установки — действующий
// доступ, секрет которого лежит ещё и в файле на диске, и оператор обязан
// иметь возможность его отозвать. Расхождения база/файл удаление не создаёт.
func TestDeletePeerAllowsRemovingBootstrapPeer(t *testing.T) {
newTestDB(t)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
if err := DeletePeer(id); err != nil {
t.Fatalf("удаление пира установщика отклонено: %v", err)
}
if _, err := dao.GetPeer("name = ?", ReservedBootstrapPeerName); err == nil {
t.Fatal("пир установщика остался в базе после удаления")
}
}
+17 -47
View File
@@ -1,59 +1,29 @@
package service package service
import ( import (
"encoding/base64"
"errors"
"strings" "strings"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util" "hy2xs-admin/util"
) )
func getOrCreateConfigKey(key string, size int) (string, error) { // Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
cfg, err := dao.GetConfig("key = ?", key) //
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { // Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
return strings.TrimSpace(*cfg.Value), nil // getPeerSecretEncryptionKey — построчные копии одноимённых функций из
} // dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
raw, genErr := util.RandomString(size) // в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
if genErr != nil { // service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
return "", genErr // но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
} // расшифровываются другим, и проявилось бы это на живых пирах.
value := raw //
remark := key // Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
if _, saveErr := dao.SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil { // из-за которого каждая первая загрузка печатала в журнал
if updErr := dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil { // `duplicated key not allowed` уровня error на здоровом старте.
return "", updErr
}
}
return value, nil
}
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) { func GetPeerSecretKey() (string, error) {
return getOrCreateConfigKey(constant.PeerSecretKey, 48) return dao.GetOrCreatePeerSecretDigestKey()
}
func getPeerSecretEncryptionKey() ([]byte, error) {
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32)
if err != nil {
return nil, err
}
decoded, decErr := util.DecodeBase64Key(raw, 32)
if decErr == nil {
return decoded, nil
}
// legacy/plain bootstrap path: convert to stable base64 once
plain := []byte(strings.TrimSpace(raw))
if len(plain) < 32 {
return nil, errors.New("invalid peer secret encryption key")
}
plain = plain[:32]
encoded := base64.StdEncoding.EncodeToString(plain)
if updErr := dao.UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); updErr != nil {
return nil, updErr
}
return plain, nil
} }
func PeerSecretDigest(rawSecret string) (string, error) { func PeerSecretDigest(rawSecret string) (string, error) {
@@ -65,7 +35,7 @@ func PeerSecretDigest(rawSecret string) (string, error) {
} }
func EncryptPeerSecret(rawSecret string) (string, error) { func EncryptPeerSecret(rawSecret string) (string, error) {
key, err := getPeerSecretEncryptionKey() key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -76,7 +46,7 @@ func DecryptPeerSecret(stored string) (string, error) {
if !strings.HasPrefix(stored, "v1:") { if !strings.HasPrefix(stored, "v1:") {
return stored, nil return stored, nil
} }
key, err := getPeerSecretEncryptionKey() key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil { if err != nil {
return "", err return "", err
} }
+25 -15
View File
@@ -15,16 +15,22 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func SHA224String(password string) string { // Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
hash := sha256.New224() //
hash.Write([]byte(password)) // Это был формат хранения паролей предыдущего поколения: несолёный SHA-224,
val := hash.Sum(nil) // то есть быстрый хеш общего назначения, по которому словарная атака идёт
str := "" // на порядки быстрее, чем по bcrypt, а одинаковые пароли дают одинаковые
for _, v := range val { // хеши. VerifyPassword принимал такие хеши как «legacy» и молча переписывал
str += fmt.Sprintf("%02x", v) // их в bcrypt при первом успешном входе.
} //
return str // В HY2XS v1 такого хеша не может существовать. Единственные места, создающие
} // учётную запись, — ensureSecureBootstrapAdmin и `reset-admin`, и оба пишут
// bcrypt; миграции 002/003, переносившие таблицу `account` предыдущего
// поколения, удалены; установка возможна только на чистый хост, а конфигурация
// 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION. То есть compatibility-ветка
// пережила ровно тот слой совместимости, ради которого существовала, и
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
// логина, доступном без аутентификации.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
if len(strings.TrimSpace(password)) < 6 { if len(strings.TrimSpace(password)) < 6 {
@@ -41,12 +47,16 @@ func IsBcryptHash(hash string) bool {
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
} }
func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) { // VerifyPassword проверяет пароль ровно против одного формата хранения.
if IsBcryptHash(storedHash) { //
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) // Хеш, который не является bcrypt, — это не «пароль в старом формате», а
return err == nil, false // повреждённая или подменённая строка в базе, и правильный ответ на неё —
// отказ во входе.
func VerifyPassword(password string, storedHash string) bool {
if !IsBcryptHash(storedHash) {
return false
} }
return SHA224String(password) == storedHash, true return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) == nil
} }
func HmacSHA256Hex(payload string, secret string) string { func HmacSHA256Hex(payload string, secret string) string {
+96 -3
View File
@@ -1,7 +1,100 @@
package util package util
import "testing" import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func TestSHA224String(t *testing.T) { // Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
println(SHA224String("sysadmin")) // проверял. Вместе с самой функцией он удалён: формат хранения паролей
// предыдущего поколения в v1 недостижим, а тест, не содержащий утверждений,
// создавал видимость покрытия.
func TestHashPasswordProducesBcrypt(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !IsBcryptHash(hash) {
t.Fatalf("HashPassword вернул не bcrypt: %q", hash)
}
}
// Соль обязана делать два хеша одного пароля различными: именно этого не
// давал прежний несолёный SHA-224.
func TestHashPasswordIsSalted(t *testing.T) {
first, err := HashPassword("one password for both")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
second, err := HashPassword("one password for both")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if first == second {
t.Fatal("два хеша одного пароля совпали: соль не применяется")
}
if !VerifyPassword("one password for both", first) || !VerifyPassword("one password for both", second) {
t.Fatal("солёные хеши не проходят проверку")
}
}
func TestHashPasswordRejectsShortPassword(t *testing.T) {
if _, err := HashPassword("12345"); err == nil {
t.Fatal("слишком короткий пароль принят")
}
if _, err := HashPassword(" "); err == nil {
t.Fatal("пароль из пробелов принят")
}
}
func TestVerifyPasswordAcceptsOnlyCorrectPassword(t *testing.T) {
hash, err := HashPassword("s3cret-passphrase")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !VerifyPassword("s3cret-passphrase", hash) {
t.Error("верный пароль отклонён")
}
if VerifyPassword("s3cret-passphras", hash) {
t.Error("неверный пароль принят")
}
if VerifyPassword("", hash) {
t.Error("пустой пароль принят")
}
}
// Главная регрессия: несолёный SHA-224 из предыдущего поколения больше не
// является допустимым форматом хранения. Раньше VerifyPassword сравнивал с ним
// напрямую и возвращал признак legacy, то есть вход по такому хешу удавался.
func TestVerifyPasswordRejectsLegacySha224Hash(t *testing.T) {
password := "sysadmin"
sum := sha256.Sum224([]byte(password))
legacyHash := hex.EncodeToString(sum[:])
if VerifyPassword(password, legacyHash) {
t.Fatal("вход по несолёному SHA-224 всё ещё возможен")
}
if IsBcryptHash(legacyHash) {
t.Fatal("SHA-224 опознан как bcrypt")
}
}
// Любая строка, не являющаяся bcrypt-хешем, — повреждённое или подменённое
// значение в базе, а не «другой формат».
func TestVerifyPasswordRejectsNonBcryptStorage(t *testing.T) {
for _, stored := range []string{
"",
"plaintext-password",
"$1$abcdefgh$0123456789abcdef",
"$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hash",
strings.Repeat("a", 60),
} {
if VerifyPassword("plaintext-password", stored) {
t.Errorf("принят пароль против не-bcrypt значения %q", stored)
}
}
} }
+44 -8
View File
@@ -1,19 +1,55 @@
package util package util
import "crypto/rand" import (
"crypto/rand"
"errors"
)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// rejectionThreshold — граница равномерности при отображении байта в алфавит.
//
// В алфавите 62 символа, а байт принимает 256 значений. 256 = 4×62 + 8,
// поэтому при прямом `charset[b % 62]` первые ВОСЕМЬ символов алфавита
// («a»…«h») выпадают пятью значениями байта, а остальные 54 — четырьмя. То
// есть распределение смещено примерно на четверть в пользу начала алфавита.
//
// Байты со значением 248 и выше отбрасываются: оставшиеся 248 = 4×62 делятся
// на длину алфавита нацело, и остаток становится равномерным. Цена — около 3%
// отброшенных байтов, то есть один лишний вызов rand.Read на несколько сотен
// символов.
//
// Практическая потеря энтропии у прежнего варианта была невелика, но это
// ЕДИНСТВЕННЫЙ генератор секретов продукта: через него проходят JWT_SECRET,
// PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY, секрет trafficStats API,
// секреты и auth_id пиров. Смещённое распределение в такой позиции чинится
// пятью строками и не обсуждается на каждом аудите заново.
const rejectionThreshold = 256 - (256 % len(charset))
// RandomString возвращает строку заданной длины из букв и цифр, равномерно
// распределённых по алфавиту.
func RandomString(length int) (string, error) { func RandomString(length int) (string, error) {
bytes := make([]byte, length) if length <= 0 {
_, err := rand.Read(bytes) return "", errors.New("random string length must be positive")
if err != nil {
return "", err
} }
for i := range bytes { out := make([]byte, 0, length)
bytes[i] = charset[int(bytes[i])%len(charset)] buf := make([]byte, length)
for len(out) < length {
if _, err := rand.Read(buf); err != nil {
return "", err
}
for _, b := range buf {
if int(b) >= rejectionThreshold {
continue
}
out = append(out, charset[int(b)%len(charset)])
if len(out) == length {
break
}
}
} }
return string(bytes), nil return string(out), nil
} }
+93
View File
@@ -0,0 +1,93 @@
package util
import (
"math"
"strings"
"testing"
)
func TestRandomStringLengthAndAlphabet(t *testing.T) {
for _, length := range []int{1, 6, 18, 24, 32, 48, 64, 300} {
value, err := RandomString(length)
if err != nil {
t.Fatalf("RandomString(%d): %v", length, err)
}
if len(value) != length {
t.Errorf("RandomString(%d) вернул строку длины %d", length, len(value))
}
for _, r := range value {
if !strings.ContainsRune(charset, r) {
t.Errorf("RandomString(%d) вернул символ %q вне алфавита", length, r)
}
}
}
}
func TestRandomStringRejectsNonPositiveLength(t *testing.T) {
for _, length := range []int{0, -1} {
if _, err := RandomString(length); err == nil {
t.Errorf("RandomString(%d) не отклонён", length)
}
}
}
// Проверка отсутствия modulo bias.
//
// Прежняя реализация делала charset[b % 62] по байту напрямую. 256 = 4×62 + 8,
// поэтому первые восемь символов алфавита выпадали пятью значениями байта из
// 256, а остальные 54 — четырьмя: перекос примерно на 25%.
//
// Тест сравнивает наблюдаемые частоты первых восьми символов алфавита с
// остальными. На выборке в 200 000 символов ожидаемая частота каждого — около
// 3226; перекос в 25% дал бы у «a»…«h» примерно 4032 против 3226, то есть
// разницу в сотни раз больше статистического шума. Порог взят с большим
// запасом, чтобы тест не был флаки: он ловит систематическое смещение, а не
// проверяет качество генератора случайных чисел.
func TestRandomStringHasNoModuloBias(t *testing.T) {
const sampleSize = 200000
value, err := RandomString(sampleSize)
if err != nil {
t.Fatalf("RandomString: %v", err)
}
counts := make(map[rune]int, len(charset))
for _, r := range value {
counts[r]++
}
expected := float64(sampleSize) / float64(len(charset))
// Восемь символов, которые смещённая реализация выдавала чаще остальных.
biasedPrefix := charset[:256%len(charset)]
var prefixTotal, restTotal float64
for _, r := range charset {
if strings.ContainsRune(biasedPrefix, r) {
prefixTotal += float64(counts[r])
} else {
restTotal += float64(counts[r])
}
}
prefixMean := prefixTotal / float64(len(biasedPrefix))
restMean := restTotal / float64(len(charset)-len(biasedPrefix))
// Смещённая реализация давала prefixMean/restMean = 5/4 = 1.25.
// Допуск 5% оставляет между порогом и дефектом кратный зазор.
ratio := prefixMean / restMean
if math.Abs(ratio-1) > 0.05 {
t.Errorf(
"распределение смещено: начало алфавита %.1f, остальные %.1f, отношение %.4f",
prefixMean, restMean, ratio,
)
}
// Ни один символ алфавита не должен пропасть или доминировать.
for _, r := range charset {
count := float64(counts[r])
if math.Abs(count-expected)/expected > 0.15 {
t.Errorf("символ %q встретился %d раз при ожидаемых %.0f", r, counts[r], expected)
}
}
}
+46 -2
View File
@@ -59,16 +59,19 @@ HY2XS_TARGET_OS=debian
HY2XS_TARGET_OS_VERSION=13 HY2XS_TARGET_OS_VERSION=13
HY2XS_TARGET_ARCH=amd64 HY2XS_TARGET_ARCH=amd64
GO_VERSION=1.21.13 GO_VERSION=1.26.7
GO_LINUX_AMD64_SHA256=<sha256> GO_LINUX_AMD64_SHA256=<sha256>
BUN_VERSION=1.3.13 BUN_VERSION=1.3.13
BUN_LINUX_X64_SHA256=<sha256> BUN_LINUX_X64_SHA256=<sha256>
BUN_LINUX_X64_BASELINE_SHA256=<sha256> BUN_LINUX_X64_BASELINE_SHA256=<sha256>
NODE_VERSION=20.19.0 NODE_VERSION=24.20.0
NODE_LINUX_X64_SHA256=<sha256> NODE_LINUX_X64_SHA256=<sha256>
PNPM_VERSION=9.15.9 PNPM_VERSION=9.15.9
HYSTERIA_CHANNEL=stable HYSTERIA_CHANNEL=stable
GOVULNCHECK_VERSION=v1.7.0
PNPM_AUDIT_LEVEL=high
``` ```
Чего в нём **нет** и быть не должно: Чего в нём **нет** и быть не должно:
@@ -86,6 +89,47 @@ HYSTERIA_CHANNEL=stable
выбирает `bun-linux-x64` или `bun-linux-x64-baseline` по наличию AVX2, поэтому выбирает `bun-linux-x64` или `bun-linux-x64-baseline` по наличию AVX2, поэтому
одной контрольной суммы архитектурно недостаточно. одной контрольной суммы архитектурно недостаточно.
### Почему версии toolchain — это вопрос безопасности, а не удобства
Go здесь не просто сборщик: им компилируется `hy2xs-admin`, и его stdlib целиком
попадает в production-бинарь. Поэтому версия выбирается по политике поддержки Go
(major поддерживается, пока не вышли две более новые), а не по тому, на чём
собиралось раньше.
Цифры, ради которых это записано. На `GO_VERSION=1.21.13` — линия, давно вне
поддержки — `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в
одной только stdlib. После перехода на 1.26.7 и обновления графа зависимостей —
**ноль**.
Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL,
то есть перестала получать security-обновления, а собирает она код, который
уезжает в production. Отсюда LTS-линия 24.
Bun обновляется отдельно от остальных: оркестратор собирается через
`bun build --compile`, то есть Bun runtime физически входит в исполняемый файл.
Смена его minor-версии — это смена рантайма внутри артефакта, и она требует
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
общем патче.
### Проверка зависимостей на уязвимости
`tools/build/lib/security.sh` — обязательный шаг сборки между тестами админки и
записью metadata:
| Проверка | Что покрывает | Порог |
| --- | --- | --- |
| `govulncheck ./...` | Go-граф **и stdlib**, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода | любая вызываемая |
| `pnpm audit --prod` | production-зависимости frontend, без анализа достижимости | `PNPM_AUDIT_LEVEL` |
Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается
на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.
Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`, по той же логике, что и
`ALLOW_DIRTY_BUILD`: выпустить релиз, зная об уязвимости, можно, но это решение
человека, а не поведение по умолчанию. Результат шага уезжает в
`metadata/package.env` полем `dependency_security_gate`, так что по готовому
tarball видно, проверялся он или собран с пропущенной проверкой.
### Проверка, а не генерация ### Проверка, а не генерация
`profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json` `profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json`
+148 -5
View File
@@ -133,6 +133,16 @@ Hysteria обращается к машинному endpoint'у как
сохраняет host, port и path — диагностика от него не страдает. Тот же проход сохраняет host, port и path — диагностика от него не страдает. Тот же проход
применяется к `journal-*.log` внутри diagnostics-бандла оркестратора. применяется к `journal-*.log` внутри diagnostics-бандла оркестратора.
**Собственный журнал админки проходит тот же санитайз.** Раньше не проходил: он
отдавался сырым файлом через `c.File(constant.SystemLogPath)` и показывался во
вкладке без обработки. Асимметрия «чужому журналу не доверяем, своему доверяем»
ничем не обоснована — файл в обоих случаях покидает сервер и пересылается в
переписке, — и цена у неё была известна поимённо: пока bootstrap-пароль
администратора печатался в лог warning'ом, обычная кнопка выгрузки отдавала его
открытым текстом. Сам warning убран (см. ниже), но защита стоит и на выходе:
следующий неосторожный `logrus.Warnf("token=%s", …)` не превратит выгрузку
журнала в канал утечки.
## Импорт и экспорт ## Импорт и экспорт
| Операция | Статус | | Операция | Статус |
@@ -165,10 +175,8 @@ API: `getConfig` и `listConfig` принимали произвольный к
| Ключ | Чтение | Запись | | Ключ | Чтение | Запись |
| --- | --- | --- | | --- | --- | --- |
| `HYSTERIA2_TRAFFIC_TIME` | да | да |
| `RESET_TRAFFIC_CRON` | да | да | | `RESET_TRAFFIC_CRON` | да | да |
| `HYSTERIA2_CONFIG_REMARK` | да | нет | | `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
| `HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет | | `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
| любой другой | нет | нет | | любой другой | нет | нет |
@@ -178,8 +186,53 @@ Denylist требует, чтобы автор каждого нового кл
по умолчанию не зависит от внимательности. по умолчанию не зависит от внимательности.
Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не
было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. Право было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь.
записи `HYSTERIA2_CONFIG_REMARK` тоже убрано: панель его только отображает.
### Мёртвое состояние в таблице `config` удалено
Четыре ключа предыдущего поколения к v1 перестали чем-либо управлять и удалены
вместе со строками в базе (миграция `006_drop_dead_config_keys`):
| Ключ | Что с ним было не так |
| --- | --- |
| `HYSTERIA2_ENABLE` | жизненным циклом Hysteria владеет systemd; единственным потребителем ключа была строка в журнале |
| `HYSTERIA2_CONFIG` | второй источник истины рядом с `/etc/hysteria/config.yaml`, причём читался **первым**: значение, попавшее в базу в обход продукта, молча становилось тем, что панель показывает и из чего генерирует ссылки |
| `HYSTERIA2_TRAFFIC_TIME` | настройка «период учёта трафика» без единого потребителя в рантайме: интервал сбора метрик задан в коде |
| `HYSTERIA2_CONFIG_REMARK` | пустая read-only строка, которую никто никогда не записывал |
Настоящую замену получил только последний: имя профиля в клиентской ссылке
теперь выводится из **имени пира**, а при его отсутствии — из публичного хоста.
Это то различие, которое пользователю и нужно видеть в списке серверов, и оно не
требует ни одной дополнительной настройки.
После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`, — а
всё остальное в таблице является внутренними секретами.
### Расписание сброса трафика: планировщик принадлежит процессу
Смена `RESET_TRAFFIC_CRON` **не перезапускает** HTTP-сервер.
Раньше перезапускала: обработчик вызывал `StopServer()`, точка входа крутила
`for { runServer() }` и поднимала сервис заново, чтобы новый планировщик прочитал
настройку из базы. При этом `InitCron()` на каждом вызове создавал новый
`cron.New()` и нигде не сохранял ссылку, а `cron.Stop()` не вызывался нигде.
Итог: каждая правка расписания добавляла **целый дублирующий набор джоб**, а
старое расписание сброса продолжало работать. После двух правок на процессе
висели три планировщика и три разных расписания одновременно.
Теперь фиксированные джобы регистрируются один раз за жизнь процесса, а
расписание сброса переносится на месте по своему `EntryID`.
Выражение проверяется **до записи в базу** тем же парсером
(`cron.ParseStandard`), которым его потом разбирает планировщик. Невалидное
значение — отказ `4xx`, база не меняется. Раньше строка сохранялась, API отвечал
успехом, а сброс трафика молча исчезал до следующего чтения журнала.
Пустое значение легально и означает «автоматический сброс выключен».
Сервис завершается штатно по `SIGTERM`: сначала останавливается планировщик и
дожидаются запущенные джобы, затем закрывается SQLite. Обратный порядок означал
бы работу джоб с уже закрытым соединением при каждом `systemctl restart`.
Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, — Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, —
«этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет», «этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет»,
@@ -191,6 +244,23 @@ Denylist требует, чтобы автор каждого нового кл
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
`export/` больше не существует. `export/` больше не существует.
### Партия настроек применяется целиком или не применяется вовсе
`POST /api/config/updateConfigs` выполняется в три прохода:
1. проверка партии целиком — права на ключ, дубликаты ключей, значения;
2. одна транзакция базы;
3. применение к рантайму.
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал.
Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на
втором — оператор получал отказ на запрос, который систему уже изменил.
Тест на этот случай существовал, но ставил запрещённый ключ **первым** и не
смотрел в базу — поймать частичное применение он был неспособен по построению.
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
проверяется явно: `TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden`.
### Экспорт пиров: два режима, а не флаг ### Экспорт пиров: два режима, а не флаг
| Кнопка | Запрос | Что внутри | | Кнопка | Запрос | Что внутри |
@@ -380,6 +450,72 @@ upstream выберет для нового секрета. Список мар
- `HY2XS_ADMIN_DATA_DIR` - `HY2XS_ADMIN_DATA_DIR`
- `HY2XS_ADMIN_LOG_DIR` - `HY2XS_ADMIN_LOG_DIR`
## Учётные данные и аутентификация
### Bootstrap-учётные данные приходят от оркестратора
Первая учётная запись администратора создаётся из `HY2XS_ADMIN_INITIAL_PASSWORD`,
пир установщика — из `HY2XS_ADMIN_CON_PASS`. Оба значения задаёт оркестратор
через `/etc/hy2xs/hy2xs.env`, а копию кладёт в
`/etc/hy2xs/bootstrap-admin.secret`.
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
старте** с сообщением, называющим причину и способ починки.
Раньше она в этом случае придумывала пароль сама и печатала его двумя
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
утечки, у такого пароля была вторая проблема: его не знал никто, кроме журнала.
Попадание в эту ветку означает не «нужно что-то придумать», а повреждённый
контракт запуска, и реакция на него должна быть громкой.
Для пира установщика цена ошибки ещё конкретнее: его секрет продублирован в
`bootstrap-admin.secret`, откуда его читает проверка machine-auth в smoke
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
подключения провалилась бы на корректном во всём остальном сервере.
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать
запись, и только потом требуется переменная. Перезапуск уже установленного
сервиса без неё работает штатно.
### Пир установщика защищён во всех путях записи
`bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя
чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла
только в импорте, то есть ровно то, ради чего она существует, делалось через
интерфейс.
Удаление и отключение при этом **разрешены**: после установки это обычный
действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор
обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не
создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
### Токены и пароли
Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 —
не косметика: у `github.com/golang-jwt/jwt` v3.2.2 есть GO-2025-3553, у которой
**нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый код
достигается из разбора токена, то есть с неаутентифицированного запроса.
Обновлять было нечего — лечится только сменой мажорной ветки.
Заодно закрыт тихий недостаток прежней реализации: `keyfunc` возвращал ключ,
**не проверяя алгоритм подписи**, то есть набор допустимых алгоритмов
фактически задавал сам токен. Сейчас разбор ограничен `jwt.WithValidMethods`,
проверяются `issuer` и обязательное наличие срока жизни, а пустой `JWT_SECRET`
считается повреждённым состоянием, а не ключом нулевой длины.
Пароли администратора хранятся ровно в одном формате — bcrypt. Ветка сравнения
с несолёным SHA-224 (формат предыдущего поколения) удалена: в v1 такой хеш не
может появиться — миграции таблицы `account` удалены, установка возможна только
на чистый хост, а конфигурация 0.x отклоняется по `HY2XS_CONFIG_SCHEMA_VERSION`.
Compatibility-ветка пережила слой совместимости, ради которого существовала, и
осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина.
Все секреты продукта генерируются одним примитивом `util.RandomString` с
отбраковкой (rejection sampling): прежняя реализация брала остаток байта от
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
примерно на четверть чаще остальных.
## Инварианты ## Инварианты
Схема считается корректной, если: Схема считается корректной, если:
@@ -394,3 +530,10 @@ upstream выберет для нового секрета. Список мар
8. экспорт конфига сохраняет неизвестные upstream-поля 8. экспорт конфига сохраняет неизвестные upstream-поля
9. экспорт конфига не содержит секретов 9. экспорт конфига не содержит секретов
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую 10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
13. партия настроек применяется целиком или не применяется вовсе
14. в таблице `config` нет ключей без потребителя
15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
16. любой журнал, покидающий сервер, проходит санитайз
17. пароль администратора хранится ровно в одном формате — bcrypt
+117 -2
View File
@@ -293,7 +293,81 @@ wildcard-маршрутом фронтенда или дублирующая р
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт, - отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
что обработчик не падает, это и доказывает); что обработчик не падает, это и доказывает);
- ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого - ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого
ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`. ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`;
- удалённые ключи (`HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`,
`HYSTERIA2_TRAFFIC_TIME`, `HYSTERIA2_CONFIG_REMARK`) отклоняются как
неизвестные — проверка идёт по строковым литералам, потому что соответствующих
констант в коде уже нет и появиться они не должны.
Атомарность партии проверяется на **настоящей** SQLite: без базы утверждение
«партия не применилась частично» бессмысленно, поскольку предметом утверждения
является именно состояние базы.
- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение
первого ключа в базе **не изменилось**;
- невалидное cron-выражение → отказ, значение в базе не изменилось;
- один ключ дважды в партии → отказ (какое из двух значений считать намерением
оператора, определить нельзя);
- корректная партия → значение сохранено, расписание применено к планировщику,
число его записей не выросло.
Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ
**первым** и до второго элемента не доходила, поэтому проходила и на реализации,
которая проверяла и записывала настройки в одном цикле.
## A9b. Планировщик (unit)
`apps/service/cron_scheduler_test.go` — планировщик как собственность процесса:
- четыре последовательные смены расписания **не увеличивают** число записей
планировщика (главная регрессия: раньше каждая смена добавляла целый
дублирующий набор джоб, а старое расписание продолжало работать);
- пустое выражение снимает джобу сброса, непустое возвращает её — без
перезапуска процесса;
- невалидное выражение не меняет планировщик и не снимает действующую джобу;
- набор валидных и невалидных выражений проверяется тем же парсером, что и
runtime: то, что `cron` умеет, обязано приниматься, остальное — отклоняться;
- невалидное значение в базе **не роняет старт**: на панели висит
`/internal/hysteria/auth`, и отказ старта из-за строки расписания положил бы
подключения пользователей. Фиксированные джобы поднимаются, сброс отключён,
в журнале ERROR, и настройка чинится через API без перезапуска;
- второй `InitCron` поверх работающего отклоняется;
- `StopCron` идемпотентен и оставляет планировщик пустым.
## A9c. Токены и пароли (unit)
`apps/service/jwt_test.go`:
- round-trip: claims, включая `token_version`, переживают выписку и разбор;
- токен, подписанный **другим** HMAC-алгоритмом тем же ключом, отклоняется
(прежний `keyfunc` не смотрел на `token.Method` вовсе);
- токен без `exp` отклоняется, истёкший отклоняется отдельным сообщением;
- токен с чужим `issuer` отклоняется даже при совпадении ключа;
- пустой `JWT_SECRET` — отказ и на выписку, и на разбор, а не подпись ключом
нулевой длины.
`apps/util/encrypt_test.go`:
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
- любая не-bcrypt строка в поле хеша отклоняется.
`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов
частоты первых восьми символов алфавита не отличаются от остальных более чем на
5%. Прежняя реализация давала здесь отношение 1.25.
## A9d. Пир установщика (unit)
`apps/service/peer_bootstrap_guard_test.go` — защита действует во всех путях
записи, а не только в импорте:
- смена секрета и переименование `bootstrap-admin-peer` отклоняются, состояние
в базе не меняется;
- переименование обычного пира в зарезервированное имя отклоняется;
- создание пира с зарезервированным именем отклоняется;
- отключение и изменение квоты **разрешены**;
- удаление **разрешено**: это осознанное действие оператора, и расхождения
между базой и `bootstrap-admin.secret` оно не создаёт.
## A10. Импорт пиров (unit) ## A10. Импорт пиров (unit)
@@ -349,6 +423,43 @@ metadata пакета и версией, которую сообщает соб
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр, `hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
противоречивые записи, отсутствие нужной строки. противоречивые записи, отсутствие нужной строки.
## A11. Проверка зависимостей на уязвимости (build)
`tools/build/lib/security.sh` — обязательный шаг между тестами админки и записью
metadata. Подробности в [docs/02](02-build-layer-and-package.md); здесь важно
поведение при отказе:
| Код `govulncheck` | Трактовка |
| --- | --- |
| `0` | чисто |
| `3` | найдены **вызываемые** уязвимости → сборка падает |
| иное | отказ самого инструмента → сборка падает отдельным сообщением |
Последняя строка существенна: ненулевой код неизвестной природы нельзя
трактовать как «уязвимостей нет». По той же причине недоступность реестра npm
для `pnpm audit` — это отказ проверки, а не её отрицательный результат.
## A12. Приёмка проверяет код, а не упоминания
Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на
корректном коде**, и оба — по одной причине: они искали подстроку там, где
подстрока обязана присутствовать.
| Проверка | Что ловила на самом деле |
| --- | --- |
| `verify_api_namespace_contract` | `grep -rlF '/hui'` возвращал `apps/router/router_test.go` (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам `versions.sh`, где эта строка стоит в тексте проверки |
| «peer import не выходит за транзакцию» | `source.slice(start)` брал файл от начала `applyPeerImportEntry` **и до конца**, захватывая `ExistPeerName` и `UpdatePeerLastConnectionAt` — обычные операции вне импорта, которым глобальное соединение положено |
Первая падала на шаге versions contract — шестым из четырнадцати, до резолва
Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила.
Отсюда правило и помощники `code_without_comments` / `code_mentions_in` в
`acceptance.sh`: проверка смотрит на код, а комментарий, объясняющий, почему
чего-то больше нет, обязан называть это по имени и не должен ломать сборку.
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом.
## B. Target install tests ## B. Target install tests
### На чистом Debian 13 проверяем ### На чистом Debian 13 проверяем
@@ -384,9 +495,13 @@ metadata пакета и версией, которую сообщает соб
17. `nft -c -f /etc/nftables.conf` проходит после apply 17. `nft -c -f /etc/nftables.conf` проходит после apply
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin` 18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
19. остановка/рестарт UI не останавливает `hysteria-server` 19. остановка/рестарт UI не останавливает `hysteria-server`
20. traffic accounting/kick ориентируются на systemd status, а не на SQLite `HYSTERIA2_ENABLE` 20. traffic accounting/kick ориентируются на systemd status; ключа `HYSTERIA2_ENABLE` в базе больше нет
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin` 21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать 22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
23. смена расписания сброса трафика применяется **без** перезапуска `hy2xs-admin`, и число джоб планировщика не растёт
24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
25. `systemctl restart hy2xs-admin` завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет `database is closed`
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
## C1. Семантический smoke конфига ## C1. Семантический smoke конфига
+60
View File
@@ -280,6 +280,66 @@ Update the DNS A record before using this server.
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке. `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке.
Для ротации существующих credentials нужен отдельный flow на уровне account-management. Для ротации существующих credentials нужен отдельный flow на уровне account-management.
### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не задан»
Означает, что учётной записи администратора в базе нет, а переменной, из которой
её положено создать, — тоже.
Придумывать пароль самостоятельно админка не будет: такой пароль не знал бы
никто, кроме журнала, а раньше именно он туда и попадал открытым текстом.
Сообщение говорит о повреждённом контракте запуска.
Что проверять:
```bash
systemctl cat hy2xs-admin | grep EnvironmentFile
grep -c '^HY2XS_ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/hy2xs.env
grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
```
Починка — `hy2xs-orchestrator repair --allow-partial-state`: значения принадлежат
оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в
согласованное состояние.
Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика.
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
проверка подключения провалилась бы.
### Забыт пароль администратора
```bash
systemctl stop hy2xs-admin
set -a; . /etc/hy2xs/hy2xs.env; set +a
"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin
systemctl start hy2xs-admin
```
Runtime env подключается намеренно: из него берутся пути к базе и журналу
(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит.
Команда печатает новые логин и пароль в консоль и требует смены пароля при
первом входе. Работает поверх существующей установки; на машине без базы она
осмысленно откажет — это инструмент восстановления, а не установки.
### Автоматический сброс трафика не срабатывает
Проверьте сохранённое расписание:
```bash
journalctl -u hy2xs-admin | grep RESET_TRAFFIC_CRON
```
Невалидное выражение теперь отклоняется API до записи в базу, поэтому попасть в
это состояние можно только правкой базы в обход продукта. Планировщик в таком
случае поднимается без джобы сброса и пишет ERROR — старт сервиса при этом не
прерывается намеренно: на панели висит `/internal/hysteria/auth`, и её отказ
положил бы подключения пользователей.
Починка — сохранить корректное значение в разделе настроек панели; оно
применяется сразу, без перезапуска сервиса. Пустое значение — легальное и
означает «автоматический сброс выключен».
## Правила эксплуатации ## Правила эксплуатации
1. Не править сервер как будто на нём есть builder. 1. Не править сервер как будто на нём есть builder.
+9
View File
@@ -19,6 +19,8 @@ load_versions_contract
. "$BUILD_DIR/lib/hysteria.sh" . "$BUILD_DIR/lib/hysteria.sh"
# shellcheck source=tools/build/lib/acceptance.sh # shellcheck source=tools/build/lib/acceptance.sh
. "$BUILD_DIR/lib/acceptance.sh" . "$BUILD_DIR/lib/acceptance.sh"
# shellcheck source=tools/build/lib/security.sh
. "$BUILD_DIR/lib/security.sh"
# shellcheck source=tools/build/lib/package.sh # shellcheck source=tools/build/lib/package.sh
. "$BUILD_DIR/lib/package.sh" . "$BUILD_DIR/lib/package.sh"
@@ -58,6 +60,13 @@ main() {
log_step "Checking HY2XS admin contracts" log_step "Checking HY2XS admin contracts"
run_admin_tests run_admin_tests
# Проверка уязвимостей идёт после bundle_ui по двум причинам: govulncheck
# анализирует пакеты, а `go:embed all:dist` не собирается без ассетов; pnpm
# audit требует установленных зависимостей frontend, которые кладёт
# `pnpm install --frozen-lockfile` внутри bundle_ui.
log_step "Checking dependency vulnerabilities"
run_dependency_security_gate
log_step "Writing metadata" log_step "Writing metadata"
write_metadata "$PACKAGE_VERSION" "$BUILD_ID" write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
+205 -1
View File
@@ -1,6 +1,39 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# code_without_comments отдаёт содержимое файла без строк-комментариев.
#
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
# документации, которая описывает выполненную ею же работу.
#
# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
# третьего раза не было.
code_without_comments() {
grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
shift
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_without_comments "$file" | grep -qF -- "$needle"; then
hits="${hits}${hits:+ }${file}"
fi
done <<EOF
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
EOF
printf '%s' "$hits"
}
run_fix20_acceptance_subset() { run_fix20_acceptance_subset() {
local package_dir="$1" local package_dir="$1"
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir" [ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
@@ -371,6 +404,165 @@ run_clean_install_acceptance() {
run_secret_channel_acceptance run_secret_channel_acceptance
run_atomic_import_acceptance run_atomic_import_acceptance
run_legacy_account_acceptance run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
[ -f apps/service/cron_scheduler.go ] \
|| fail "acceptance: модуль планировщика отсутствует"
[ ! -f apps/middleware/cron.go ] \
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
# Именно этот путь плодил планировщики — StopServer возвращал управление в
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
# внутрипроцессного перезапуска.
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
const release = source.indexOf("defer releaseResource()");
const stopCron = source.indexOf("defer service.StopCron()");
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
// defer выполняются в обратном порядке регистрации: releaseResource должен
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
grep -q 'ValidateConfigValue' apps/controller/config.go \
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
grep -q 'func WithConfigTx' apps/dao/config.go \
|| fail "acceptance: у записи настроек нет транзакционной границы"
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
# Проверка обязана завершиться ДО первой записи.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
const validate = source.indexOf("service.ValidateConfigValue(");
const apply = source.indexOf("service.UpdateConfigs(updates)");
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
}
# Мёртвые ключи таблицы `config` не должны вернуться.
run_dead_config_acceptance() {
log_step "Acceptance: dead config keys stay removed"
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
# другое обязано называть их по имени.
local dead_key hits
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
hits="$(code_mentions_in "$dead_key" \
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
[ -z "$hits" ] \
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
done
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
# была вторым источником истины и читалась ПЕРВОЙ.
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
log_step "Acceptance: share URI remark is derived, not stored as dead state"
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
}
# Гигиена зависимостей и bootstrap-учётных данных.
run_dependency_hygiene_acceptance() {
local package_dir="$1"
log_step "Acceptance: bootstrap credentials are never invented or logged"
# Ключевой канал утечки: сгенерированный пароль печатался в
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
grep -q 'func ExportAdminLog' apps/service/journal.go \
|| fail "acceptance: журнал админки выгружается без санитайза"
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
local jwt_v3_hits
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
[ -z "$jwt_v3_hits" ] \
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
log_step "Acceptance: password storage has exactly one format"
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
log_step "Acceptance: the release is built with a dependency vulnerability gate"
[ -f tools/build/lib/security.sh ] \
|| fail "acceptance: модуль проверки зависимостей отсутствует"
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|| fail "acceptance: сборка не запускает проверку зависимостей"
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
# По готовому tarball должно быть видно, проверялся он или собран с
# пропущенной проверкой — ровно как hysteria_compat_gate.
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
log_step "Acceptance: the Go toolchain is on a supported line"
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
local go_minor
go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)"
[ "$go_minor" -ge 26 ] \
|| fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib"
} }
# PHASE 1 принадлежит оркестратору целиком. # PHASE 1 принадлежит оркестратору целиком.
@@ -537,11 +729,23 @@ run_atomic_import_acceptance() {
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \ grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|| fail "acceptance: WithPeerTx must open a real database transaction" || fail "acceptance: WithPeerTx must open a real database transaction"
# Применение обязано идти по tx, а не по глобальному соединению. # Применение обязано идти по tx, а не по глобальному соединению.
#
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
# которым глобальное соединение положено. Проверка гарантированно падала на
# корректном коде и не была замечена только потому, что сборка до неё не
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
#
# Границей тела функции служит следующее объявление верхнего уровня.
"$BUN_BIN" -e ' "$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8"); const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const start = source.indexOf("func applyPeerImportEntry"); const start = source.indexOf("func applyPeerImportEntry");
if (start < 0) throw new Error("applyPeerImportEntry is missing"); if (start < 0) throw new Error("applyPeerImportEntry is missing");
const body = source.slice(start); const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) { for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call); if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
} }
+2
View File
@@ -158,6 +158,8 @@ write_metadata() {
printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}" printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}"
printf 'node_version=%s\n' "$($NODE_BIN --version)" printf 'node_version=%s\n' "$($NODE_BIN --version)"
printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)" printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)"
printf 'govulncheck_version=%s\n' "${GOVULNCHECK_VERSION:-unknown}"
printf 'dependency_security_gate=%s\n' "${DEPENDENCY_SECURITY_GATE:-unknown}"
printf 'hysteria_source=official-upstream\n' printf 'hysteria_source=official-upstream\n'
printf 'hysteria_target=linux-amd64\n' printf 'hysteria_target=linux-amd64\n'
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION" printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# Проверка графа зависимостей на известные уязвимости.
#
# Зачем отдельный шаг сборки, а не разовая проверка «когда вспомним».
#
# Версии toolchain и библиотек фиксируются в versions.env, go.sum и lock-файлах
# — то есть намеренно НЕ движутся. Это правильно для воспроизводимости и ровно
# поэтому опасно для безопасности: зафиксированный граф не стареет только на
# бумаге, а advisory по нему выходят и после фиксации. Единственный момент,
# когда расхождение между «что мы собираем» и «что про это известно» можно
# поймать дёшево, — сама сборка релиза.
#
# История, ради которой шаг появился: на Go 1.21.13 и графе поколения 0.x
# govulncheck находил 21 ВЫЗЫВАЕМУЮ уязвимость, включая GO-2025-3553 в
# github.com/golang-jwt/jwt v3 — с пометкой `Fixed in: N/A` и путём вызова из
# разбора токена, то есть достижимую с неаутентифицированного запроса. Ни один
# из существовавших шагов сборки этого показать не мог.
#
# Проверяются РАЗНЫЕ вещи в двух экосистемах:
#
# govulncheck — анализ достижимости: уязвимость считается только если до неё
# есть путь вызова из нашего кода. Покрывает и stdlib, то есть
# ровно то, что версия Go привносит в production-бинарь;
# pnpm audit — граф npm без анализа достижимости, поэтому ограничен
# production-зависимостями и порогом PNPM_AUDIT_LEVEL:
# devDependencies в артефакт не попадают.
# Аварийный выход существует по той же причине, что и ALLOW_DIRTY_BUILD: новое
# advisory может выйти в неудачный момент, и решение «выпускаем, зная о нём»
# должно приниматься человеком ЯВНО, а не пропуском шага по умолчанию.
ALLOW_VULNERABLE_DEPENDENCIES="${ALLOW_VULNERABLE_DEPENDENCIES:-false}"
security_gate_failed() {
local scanner="$1"
local details="$2"
if [ "$ALLOW_VULNERABLE_DEPENDENCIES" = "true" ]; then
log_info "WARNING: $scanner нашёл уязвимости, но сборка продолжена по ALLOW_VULNERABLE_DEPENDENCIES=true"
log_info "$details"
DEPENDENCY_SECURITY_GATE="accepted-risk"
export DEPENDENCY_SECURITY_GATE
return 0
fi
fail "$scanner: найдены уязвимости в зависимостях. Обновите граф или, осознанно принимая риск, выставьте ALLOW_VULNERABLE_DEPENDENCIES=true.
$details"
}
# Анализ Go-графа, включая stdlib выбранной версии Go.
run_go_vulnerability_gate() {
local ui_src="${UI_SRC:-apps}"
local report status
log_step "Security: govulncheck ${GOVULNCHECK_VERSION} (Go ${GO_VERSION})"
# GOTOOLCHAIN=local обязателен: без него go может молча скачать другую
# версию toolchain, и проверялась бы не та stdlib, которая попадёт в бинарь.
set +e
report="$(cd "$ui_src" && GOTOOLCHAIN=local "$GO_BIN" run \
"golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" ./... 2>&1)"
status=$?
set -e
# 0 — чисто; 3 — найдены вызываемые уязвимости; остальное — отказ самого
# инструмента, и его нельзя трактовать как «уязвимостей нет».
case "$status" in
0)
log_info "govulncheck: вызываемых уязвимостей не найдено"
;;
3)
security_gate_failed "govulncheck" "$report"
;;
*)
fail "govulncheck завершился с кодом $status (это отказ инструмента, а не результат проверки):
$report"
;;
esac
}
# Анализ production-зависимостей frontend.
run_frontend_vulnerability_gate() {
local ui_src="${UI_SRC:-apps}"
local report status
log_step "Security: pnpm audit --prod (порог ${PNPM_AUDIT_LEVEL})"
set +e
report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --prod --audit-level "$PNPM_AUDIT_LEVEL" 2>&1)"
status=$?
set -e
if [ "$status" -eq 0 ]; then
log_info "pnpm audit: уязвимостей уровня ${PNPM_AUDIT_LEVEL} и выше не найдено"
return 0
fi
# pnpm audit ходит в реестр npm. Недоступность реестра — это отказ проверки,
# а не её отрицательный результат, и молча пропускать его нельзя.
if printf '%s' "$report" | grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo'; then
fail "pnpm audit не смог обратиться к реестру npm — проверка не выполнена:
$report"
fi
security_gate_failed "pnpm audit" "$report"
}
# Результат шага уезжает в metadata/package.env: по готовому tarball должно
# быть видно, проверялся он или собран с пропущенной проверкой. Ровно так же
# устроен hysteria_compat_gate.
run_dependency_security_gate() {
if [ "${SKIP_SECURITY_SCAN:-false}" = "true" ]; then
log_info "WARNING: проверка зависимостей на уязвимости пропущена (SKIP_SECURITY_SCAN=true)"
DEPENDENCY_SECURITY_GATE="skipped"
export DEPENDENCY_SECURITY_GATE
return 0
fi
[ -n "${GOVULNCHECK_VERSION:-}" ] \
|| fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым"
[ -n "${PNPM_AUDIT_LEVEL:-}" ] \
|| fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым"
DEPENDENCY_SECURITY_GATE="true"
export DEPENDENCY_SECURITY_GATE
run_go_vulnerability_gate
run_frontend_vulnerability_gate
}
+23 -6
View File
@@ -34,6 +34,8 @@ REQUIRED_CONTRACT_VARS=(
NODE_LINUX_X64_SHA256 NODE_LINUX_X64_SHA256
PNPM_VERSION PNPM_VERSION
HYSTERIA_CHANNEL HYSTERIA_CHANNEL
GOVULNCHECK_VERSION
PNPM_AUDIT_LEVEL
) )
validate_sha256_value() { validate_sha256_value() {
@@ -99,6 +101,7 @@ load_versions_contract() {
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256 export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL
} }
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без # Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
@@ -217,17 +220,31 @@ verify_api_namespace_contract() {
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в # Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
# #
# Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше # Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
# в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть # Файлы, задача которых — доказать отсутствие legacy-пространства, из скана
# ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers. # исключены: они обязаны называть его по имени, иначе проверять нечего.
#
# Регрессия приёмки. Раньше исключений не было, а в список каталогов входили
# tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал
# apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и
# этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала
# на шаге versions contract — то есть добавление теста, закрепляющего
# очистку, гарантированно ломало сборку.
#
# Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан
# исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace
# проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование
# этого теста тоже входит в контракт и проверяется ниже.
local legacy_hits local legacy_hits
legacy_hits="$(grep -rlF '/hui' \ legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \
apps/model apps/router apps/controller apps/service apps/middleware \ apps/model apps/router apps/controller apps/service apps/middleware \
apps/cmd apps/dao apps/util apps/frontend/src \ apps/cmd apps/dao apps/util apps/frontend/src \
orchestrator/src orchestrator/test package/templates \ orchestrator/src package/templates tools/test 2>/dev/null || true)"
tools/test tools/build 2>/dev/null || true)"
[ -z "$legacy_hits" ] \ [ -z "$legacy_hits" ] \
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits" || fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \
|| fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go"
} }
verify_go_toolchain_contract() { verify_go_toolchain_contract() {
+39 -4
View File
@@ -40,21 +40,56 @@ HY2XS_TARGET_ARCH=amd64
# ------------------------------------------------------------ toolchain --- # ------------------------------------------------------------ toolchain ---
GO_VERSION=1.21.13 # Go входит в production-артефакт, а не только в сборку: им компилируется
GO_LINUX_AMD64_SHA256=502fc16d5910562461e6a6631fb6377de2322aad7304bf2bcd23500ba9dab4a7 # hy2xs-admin, и его stdlib целиком попадает в бинарь. Поэтому версия здесь
# выбирается по политике поддержки Go (major поддерживается, пока не вышли две
# более новые), а не по удобству.
#
# Было 1.21.13 — линия вне поддержки. На ней `govulncheck ./...` находил 21
# ВЫЗЫВАЕМУЮ уязвимость, из них 17 в stdlib. На 1.26.7 — ноль.
GO_VERSION=1.26.7
GO_LINUX_AMD64_SHA256=ffb5f8de10c62550dfddab66b36b57030721e0a44a3218e9e1181d7b59f121ca
# Bun выбирает артефакт по наличию AVX2, поэтому одной контрольной суммы # Bun выбирает артефакт по наличию AVX2, поэтому одной контрольной суммы
# архитектурно недостаточно: обе должны быть зафиксированы заранее. # архитектурно недостаточно: обе должны быть зафиксированы заранее.
#
# 1.3.13 оставлена сознательно, хотя 1.4 уже вышел. Оркестратор собирается
# через `bun build --compile`, то есть Bun runtime физически входит в
# исполняемый файл, а 1.4 — крупное внутреннее обновление рантайма. Такой
# переход стоит отдельного контролируемого прохода по всей матрице
# `bun test → tsc → compile → Debian acceptance`, а не строки в общем патче.
BUN_VERSION=1.3.13 BUN_VERSION=1.3.13
BUN_LINUX_X64_SHA256=79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a BUN_LINUX_X64_SHA256=79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a
BUN_LINUX_X64_BASELINE_SHA256=9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750 BUN_LINUX_X64_BASELINE_SHA256=9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750
NODE_VERSION=20.19.0 # Node живёт только на build-хосте (сборка frontend-ассетов) и в runtime-артефакт
NODE_LINUX_X64_SHA256=b4e336584d62abefad31baecff7af167268be9bb7dd11f1297112e6eed3ca0d5 # не попадает. Но 20.x достигла EOL, то есть перестала получать в том числе
# security-обновления, а собирает она код, который уезжает в production.
# 24.20.0 — текущая LTS-линия.
NODE_VERSION=24.20.0
NODE_LINUX_X64_SHA256=2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2
# pnpm ставится через corepack, отдельного архива с контрольной суммой нет. # pnpm ставится через corepack, отдельного архива с контрольной суммой нет.
#
# Линия 9 оставлена вместе с apps/frontend/pnpm-lock.yaml: переход на 10 меняет
# формат lock-файла, то есть затрагивает воспроизводимость сборки frontend, и
# относится к обновлению его зависимостей, а не toolchain.
PNPM_VERSION=9.15.9 PNPM_VERSION=9.15.9
# ------------------------------------------------ проверка зависимостей ---
# Версия govulncheck пиньтся здесь, а не берётся @latest: инструмент, которым
# проверяется релиз, сам обязан быть частью контракта сборки. Иначе один и тот
# же коммит проверяется разными анализаторами в разные дни.
#
# База уязвимостей при этом ЖИВАЯ и подтягивается на каждом запуске — это и
# нужно: пин версии инструмента не должен превращаться в пин знаний о мире.
GOVULNCHECK_VERSION=v1.7.0
# Порог pnpm audit для production-зависимостей frontend.
# Значения: low | moderate | high | critical.
PNPM_AUDIT_LEVEL=high
# ------------------------------------------------ политика выбора Hysteria --- # ------------------------------------------------ политика выбора Hysteria ---
# stable — резолвить последний стабильный upstream-релиз на build-машине; # stable — резолвить последний стабильный upstream-релиз на build-машине;