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
+12 -5
View File
@@ -35,11 +35,18 @@ func run(cmd *cobra.Command, args []string) {
fmt.Println(err.Error())
os.Exit(1)
}
for {
if err := runServer(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
// Цикла `for { runServer() }` здесь больше нет.
//
// Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON
// делала 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)
}
// Длины восстановительных учётных данных.
//
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
// единственной защитой оставался force_password_change. Пароль восстановления
// живёт до первого входа администратора, но до этого входа он полноценно
// пускает в панель, поэтому его стойкость должна соответствовать остальным
// секретам продукта, а не минимально допустимой.
const (
resetUsernameLength = 12
resetPasswordLength = 24
)
func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(6)
username, err := util.RandomString(resetUsernameLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
password, err := util.RandomString(6)
password, err := util.RandomString(resetPasswordLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
+45 -7
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/gin-gonic/gin"
@@ -14,9 +15,11 @@ import (
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func resolveUiBindHost() (string, error) {
@@ -69,9 +72,14 @@ func runServer(port string) error {
if err := dao.InitSql(); err != nil {
return err
}
if err := middleware.InitCron(); err != nil {
if err := service.InitCron(); err != nil {
return err
}
// Порядок остановки обратный порядку регистрации: планировщик глушится
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
// и обратный порядок означал бы гарантированные «database is closed» на
// каждом штатном завершении сервиса.
defer service.StopCron()
if err := service.InitHysteria2(); err != nil {
return err
}
@@ -97,19 +105,49 @@ func runServer(port string) error {
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
// TLS и заканчивается.
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() {
if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf(err.Error())
logrus.Errorf("%v", err)
}
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) {
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
if err != nil {
return
}
needRestart := false
updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos))
seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos))
for _, item := range configsUpdateDto.ConfigUpdateDtos {
key := *item.Key
@@ -63,42 +81,67 @@ func UpdateConfigs(c *gin.Context) {
return
}
if key == constant.ResetTrafficCron {
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *resetTrafficCron.Value != value {
needRestart = true
}
// Один ключ дважды в одной партии — неоднозначный запрос: какое из
// двух значений считать намерением оператора, определить нельзя.
if _, duplicate := seen[key]; duplicate {
vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c)
return
}
seen[key] = struct{}{}
if err = service.UpdateConfig(key, value); err != nil {
if err = service.ValidateConfigValue(key, value); err != nil {
vo.Fail(err.Error(), c)
return
}
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
}
if needRestart {
go func() {
_ = service.StopServer()
}()
if err = service.UpdateConfigs(updates); err != nil {
vo.Fail(err.Error(), c)
return
}
if err = applyRuntimeConfigUpdates(updates); err != nil {
vo.Fail(err.Error(), c)
return
}
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-оркестратор.
//
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
//
// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами:
// первым никто не управлял, второй был вторым источником истины рядом с
// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит
// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite.
func isOrchestratorManagedConfigKey(key string) bool {
switch key {
case constant.Hysteria2Enable,
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret:
case constant.Hysteria2TrafficStatsSecret:
return true
default:
return false
+154 -7
View File
@@ -5,11 +5,14 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/service"
)
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
@@ -72,7 +75,7 @@ func TestListConfigRefusesInternalKeys(t *testing.T) {
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
func TestListConfigRefusesMixedBatch(t *testing.T) {
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" {
@@ -109,8 +112,6 @@ func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
for _, key := range []string{
constant.Hysteria2Enable,
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret,
} {
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) {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
@@ -139,17 +157,146 @@ func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
}
}
// Отказ на любой записи партии обязан отменить партию целиком: иначе первые
// ключи применились бы, а оператор получил бы ошибку.
func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) {
// newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась
// частично» без базы бессмысленна — именно состояние базы и есть предмет
// утверждения. Схема поднимается отдельно от 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{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
{"key": constant.JwtSecret, "value": "attacker-controlled"},
{"key": constant.Hysteria2TrafficTime, "value": "10"},
},
})
if result.Type != "no" {
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)
continue
}
// Собственный журнал санитизируется так же, как чужой.
//
// Раньше через SanitizeLogText проходил только журнал Hysteria: он
// «чужой», а свой мы якобы контролируем. Контроль этот держался на
// внимательности — ровно до logrus.Warnf с паролем администратора в
// dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах
// записи, а на случай следующего такого места.
logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg)
logSystemVos = append(logSystemVos, logSystemVo)
}
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
@@ -80,10 +88,26 @@ func ExportLog(c *gin.Context) {
}
var fileName string
var filePath string
if *logExportDto.Option == 0 {
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 {
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
output, exportErr := service.ExportHysteriaJournalLogs(5000)
@@ -98,12 +122,9 @@ func ExportLog(c *gin.Context) {
return
}
if !util.Exists(filePath) {
vo.Fail("log file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
// Обе ветки выше завершаются return. Сюда попадает только неизвестное
// значение option, и это отказ, а не отдача пустого файла: раньше здесь
// оставался путь с пустым filePath, который сводился к тому же сообщению
// окольной дорогой.
vo.Fail("unsupported log export option", c)
}
+94 -8
View File
@@ -18,14 +18,100 @@ func SaveConfig(config entity.Config) (int64, error) {
}
func UpdateConfig(keys []string, updates map[string]interface{}) error {
if len(updates) > 0 {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Config{}).
Where("key in ?", keys).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return updateConfigOn(sqliteDB, keys, updates)
}
// UpsertConfigValue записывает значение ключа, создавая строку, если её нет.
//
// Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе
// были неверны:
//
// 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
}
+130 -85
View File
@@ -95,6 +95,22 @@ func envBoolAsInt(name string, fallback int) int {
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 {
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
if adminUser == "" {
@@ -105,14 +121,16 @@ func ensureSecureBootstrapAdmin() error {
}
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
passwordGenerated := false
if adminPassword == "" {
password, pwdErr := util.RandomString(18)
if pwdErr != nil {
return pwdErr
}
adminPassword = password
passwordGenerated = true
return errors.New(
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
"Это признак повреждённого контракта запуска: пароль первого администратора\n" +
"задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" +
"/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" +
"будет: такой пароль не знал бы никто, кроме журнала.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1)
@@ -135,23 +153,22 @@ func ensureSecureBootstrapAdmin() error {
if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr
}
if passwordGenerated {
logrus.Warnf("Initial admin username: %s", adminUser)
logrus.Warnf("Initial admin password: %s", adminPassword)
}
return nil
}
// ensureSecureBootstrapPeer создаёт пир установщика.
//
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
// файлом, и первая же проверка подключения после установки провалилась бы —
// на корректном во всём остальном сервере.
//
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
// без переменной падал бы на ровном месте.
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 {
return nil
}
@@ -159,6 +176,19 @@ func ensureSecureBootstrapPeer() error {
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)
if err != nil {
return err
@@ -205,15 +235,23 @@ func ensureSecureBootstrapPeer() error {
}
func buildPeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := getOrCreatePeerSecretDigestKey()
secretKey, err := GetOrCreatePeerSecretDigestKey()
if err != nil {
return "", err
}
return util.HmacSHA256Hex(rawSecret, secretKey), nil
}
func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32)
// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
// пиров, при необходимости создавая его.
//
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
// ними означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и обнаружилось бы это на живых пирах.
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
if err != nil {
return nil, err
}
@@ -221,41 +259,42 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
if decErr == nil {
return decoded, nil
}
// Ключ, записанный не в base64: приводим к стабильному представлению один
// раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров
// перестали бы расшифровываться.
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 err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil {
if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil {
return nil, err
}
return plain, nil
}
func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
key, err := getOrCreatePeerSecretEncryptionKey()
key, err := GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
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)
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
return strings.TrimSpace(*cfg.Value), nil
}
raw, genErr := util.RandomString(size)
value, genErr := util.RandomString(size)
if genErr != nil {
return "", genErr
}
value := raw
remark := key
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
}
if err := UpsertConfigValue(key, value); err != nil {
return "", err
}
return value, nil
}
@@ -300,6 +339,7 @@ func runMigrations() error {
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
{version: "005_metric_sample", apply: migrateMetricSampleTable},
{version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys},
}
for _, m := range migrations {
@@ -322,12 +362,16 @@ func runMigrations() 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{
constant.JwtSecret: "",
constant.Hysteria2Enable: "0",
constant.Hysteria2Config: "",
constant.Hysteria2TrafficTime: "10",
constant.Hysteria2ConfigRemark: "",
constant.ResetTrafficCron: "0 0 * * *",
constant.Hysteria2TrafficStatsSecret: "",
constant.PeerSecretKey: "",
@@ -363,6 +407,34 @@ func migrateBaseConfig() error {
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) {
var count int64
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 {
return "", secErr
}
if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil {
key := constant.JwtSecret
remark := "JWT signing secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return "", saveErr
}
if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
return "", err
}
return secret, nil
}
func getOrCreatePeerSecretDigestKey() (string, error) {
if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return strings.TrimSpace(*existing.Value), nil
}
}
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
// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
// логики жила в service/peer_secret.go под именем GetPeerSecretKey.
func GetOrCreatePeerSecretDigestKey() (string, error) {
return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
}
func migrateTrafficTables() error {
@@ -542,43 +597,33 @@ func tableExists(tableName string) bool {
}
return count > 0
}
// ensureTrafficStatsSecret синхронизирует machine token с окружением.
//
// Владелец значения — оркестратор: он же кладёт этот секрет в
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а
// собственная генерация — только для случая, когда переменной нет вовсе.
//
// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue:
// каждая из них по-своему решала «есть строка или нет», и две из четырёх
// решали неверно (см. комментарий к UpsertConfigValue).
func ensureTrafficStatsSecret() error {
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
if envSecret != "" {
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
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
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
}
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
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)
if secErr != nil {
return secErr
}
key := constant.Hysteria2TrafficStatsSecret
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return saveErr
}
return nil
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret)
}
func CloseSqliteDB() error {
+8 -2
View File
@@ -16,7 +16,7 @@
"dependencies": {
"@element-plus/icons-vue": "^1.0.0",
"@vueuse/core": "^9.1.1",
"axios": "^1.3.4",
"axios": "^1.20.0",
"copy-to-clipboard": "^3.3.3",
"echarts": "^5.5.1",
"element-plus": "^2.3.1",
@@ -42,7 +42,7 @@
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"postcss": "^8.4.21",
"postcss": "^8.5.26",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.7",
@@ -63,6 +63,12 @@
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^0.35.0"
},
"pnpm": {
"overrides": {
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1"
}
},
"engines": {
"node": ">=18.12.0"
}
+212 -77
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
importers:
.:
@@ -15,8 +19,8 @@ importers:
specifier: ^9.1.1
version: 9.1.1(vue@3.2.45)
axios:
specifier: ^1.3.4
version: 1.3.4
specifier: ^1.20.0
version: 1.20.0
copy-to-clipboard:
specifier: ^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)
autoprefixer:
specifier: ^10.4.13
version: 10.4.13(postcss@8.4.21)
version: 10.4.13(postcss@8.5.26)
eslint:
specifier: ^8.34.0
version: 8.34.0
@@ -88,14 +92,14 @@ importers:
specifier: ^9.9.0
version: 9.9.0(eslint@8.34.0)
postcss:
specifier: ^8.4.21
version: 8.4.21
specifier: ^8.5.26
version: 8.5.26
postcss-html:
specifier: ^1.5.0
version: 1.5.0
postcss-scss:
specifier: ^4.0.6
version: 4.0.6(postcss@8.4.21)
version: 4.0.6(postcss@8.5.26)
prettier:
specifier: ^2.8.7
version: 2.8.7
@@ -113,7 +117,7 @@ importers:
version: 4.0.0(stylelint@15.5.0(typescript@4.9.3))
stylelint-config-recommended-scss:
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:
specifier: ^1.4.0
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))
stylelint-config-standard-scss:
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:
specifier: ^4.9.3
version: 4.9.3
@@ -532,8 +536,8 @@ packages:
rollup:
optional: true
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
@@ -881,8 +885,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.3.4:
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -937,6 +941,10 @@ packages:
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
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:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -1189,6 +1197,10 @@ packages:
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@@ -1231,6 +1243,22 @@ packages:
error-ex@1.3.2:
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:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'}
@@ -1414,8 +1442,8 @@ packages:
flatted@3.2.9:
resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==}
follow-redirects@1.15.3:
resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -1427,8 +1455,8 @@ packages:
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
engines: {node: '>=0.10.0'}
form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
fraction.js@4.3.6:
@@ -1457,10 +1485,21 @@ packages:
function-bind@1.1.1:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
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:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -1512,6 +1551,10 @@ packages:
globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1542,6 +1585,14 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
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:
resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==}
engines: {node: '>=0.10.0'}
@@ -1565,6 +1616,10 @@ packages:
hash-sum@2.0.0:
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:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@@ -1848,15 +1903,15 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '*'
lodash-es: '*'
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -1864,8 +1919,8 @@ packages:
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1901,6 +1956,10 @@ packages:
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
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:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -2008,8 +2067,8 @@ packages:
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2159,6 +2218,9 @@ packages:
picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
@@ -2228,8 +2290,8 @@ packages:
resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==}
engines: {node: '>=0.12'}
postcss@8.4.21:
resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
posthtml-parser@0.2.1:
@@ -2265,8 +2327,9 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@@ -2458,6 +2521,10 @@ packages:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
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:
resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==}
deprecated: See https://github.com/lydell/source-map-resolve#deprecated
@@ -3389,7 +3456,7 @@ snapshots:
optionalDependencies:
rollup: 3.29.3
'@sxzz/popperjs-es@2.11.7': {}
'@sxzz/popperjs-es@2.11.8': {}
'@trysound/sax@0.2.0': {}
@@ -3682,7 +3749,7 @@ snapshots:
'@vue/shared': 3.2.45
estree-walker: 2.0.2
magic-string: 0.25.9
postcss: 8.4.21
postcss: 8.5.26
source-map: 0.6.1
'@vue/compiler-sfc@3.3.4':
@@ -3695,7 +3762,7 @@ snapshots:
'@vue/shared': 3.3.4
estree-walker: 2.0.2
magic-string: 0.30.3
postcss: 8.4.21
postcss: 8.5.26
source-map-js: 1.0.2
'@vue/compiler-ssr@3.2.45':
@@ -3843,23 +3910,25 @@ snapshots:
atob@2.1.2: {}
autoprefixer@10.4.13(postcss@8.4.21):
autoprefixer@10.4.13(postcss@8.5.26):
dependencies:
browserslist: 4.21.11
caniuse-lite: 1.0.30001539
fraction.js: 4.3.6
normalize-range: 0.1.2
picocolors: 1.0.0
postcss: 8.4.21
postcss: 8.5.26
postcss-value-parser: 4.2.0
axios@1.3.4:
axios@1.20.0:
dependencies:
follow-redirects: 1.15.3
form-data: 4.0.0
proxy-from-env: 1.1.0
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
balanced-match@1.0.2: {}
@@ -3948,6 +4017,11 @@ snapshots:
union-value: 1.0.1
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: {}
camelcase-keys@6.2.2:
@@ -4197,6 +4271,12 @@ snapshots:
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: {}
echarts@5.6.0:
@@ -4211,16 +4291,16 @@ snapshots:
'@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.1.0(vue@3.2.45)
'@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-es': 4.17.9
'@vueuse/core': 9.1.1(vue@3.2.45)
async-validator: 4.2.5
dayjs: 1.11.10
escape-html: 1.0.3
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified: 1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.17.21)(lodash@4.17.21)
lodash: 4.18.1
lodash-es: 4.18.1
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
normalize-wheel-es: 1.2.0
vue: 3.2.45
@@ -4251,6 +4331,21 @@ snapshots:
dependencies:
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:
optionalDependencies:
'@esbuild/android-arm': 0.17.19
@@ -4505,14 +4600,16 @@ snapshots:
flatted@3.2.9: {}
follow-redirects@1.15.3: {}
follow-redirects@1.16.0: {}
for-in@1.0.2: {}
form-data@4.0.0:
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
fraction.js@4.3.6: {}
@@ -4538,8 +4635,28 @@ snapshots:
function-bind@1.1.1: {}
function-bind@1.1.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-value@2.0.6: {}
@@ -4608,6 +4725,8 @@ snapshots:
globjoin@0.1.4: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
grapheme-splitter@1.0.4: {}
@@ -4628,6 +4747,12 @@ snapshots:
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:
dependencies:
get-value: 2.0.6
@@ -4653,6 +4778,10 @@ snapshots:
hash-sum@2.0.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hookable@5.5.3: {}
@@ -4885,19 +5014,19 @@ snapshots:
dependencies:
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:
'@types/lodash-es': 4.17.9
lodash: 4.17.21
lodash-es: 4.17.21
lodash: 4.18.1
lodash-es: 4.18.1
lodash.merge@4.6.2: {}
lodash.truncate@4.4.2: {}
lodash@4.17.21: {}
lodash@4.18.1: {}
lru-cache@5.1.1:
dependencies:
@@ -4929,6 +5058,8 @@ snapshots:
dependencies:
object-visit: 1.0.1
math-intrinsics@1.1.0: {}
mathml-tag-names@2.1.3: {}
mdn-data@2.0.14: {}
@@ -5047,7 +5178,7 @@ snapshots:
ms@2.1.2: {}
nanoid@3.3.6: {}
nanoid@3.3.18: {}
nanomatch@1.2.13:
dependencies:
@@ -5197,6 +5328,8 @@ snapshots:
picocolors@1.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
pinia@2.0.33(typescript@4.9.3)(vue@3.2.45):
@@ -5219,8 +5352,8 @@ snapshots:
dependencies:
htmlparser2: 8.0.2
js-tokens: 8.0.2
postcss: 8.4.21
postcss-safe-parser: 6.0.0(postcss@8.4.21)
postcss: 8.5.26
postcss-safe-parser: 6.0.0(postcss@8.5.26)
postcss-import-resolver@2.0.0:
dependencies:
@@ -5234,22 +5367,22 @@ snapshots:
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:
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:
postcss: 8.4.21
postcss: 8.5.26
postcss-selector-parser@6.0.13:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-sorting@8.0.2(postcss@8.4.21):
postcss-sorting@8.0.2(postcss@8.5.26):
dependencies:
postcss: 8.4.21
postcss: 8.5.26
postcss-value-parser@4.2.0: {}
@@ -5260,11 +5393,11 @@ snapshots:
source-map: 0.5.7
supports-color: 3.2.3
postcss@8.4.21:
postcss@8.5.26:
dependencies:
nanoid: 3.3.6
picocolors: 1.0.0
source-map-js: 1.0.2
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
posthtml-parser@0.2.1:
dependencies:
@@ -5299,7 +5432,7 @@ snapshots:
process-nextick-args@2.0.1: {}
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
prr@1.0.1: {}
@@ -5487,6 +5620,8 @@ snapshots:
source-map-js@1.0.2: {}
source-map-js@1.2.1: {}
source-map-resolve@0.5.3:
dependencies:
atob: 2.1.2
@@ -5578,23 +5713,23 @@ snapshots:
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:
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-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))
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:
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-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))
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)):
dependencies:
@@ -5616,13 +5751,13 @@ snapshots:
dependencies:
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:
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))
optionalDependencies:
postcss: 8.4.21
postcss: 8.5.26
stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)):
dependencies:
@@ -5631,8 +5766,8 @@ snapshots:
stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)):
dependencies:
postcss: 8.4.21
postcss-sorting: 8.0.2(postcss@8.4.21)
postcss: 8.5.26
postcss-sorting: 8.0.2(postcss@8.5.26)
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
normalize-path: 3.0.0
picocolors: 1.0.0
postcss: 8.4.21
postcss: 8.5.26
postcss-media-query-parser: 0.2.3
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-value-parser: 4.2.0
resolve-from: 5.0.0
@@ -6010,7 +6145,7 @@ snapshots:
vite@4.3.1(@types/node@20.7.0)(sass@1.58.3):
dependencies:
esbuild: 0.17.19
postcss: 8.4.21
postcss: 8.5.26
rollup: 3.29.3
optionalDependencies:
'@types/node': 20.7.0
@@ -6043,7 +6178,7 @@ snapshots:
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.5.0
lodash: 4.17.21
lodash: 4.18.1
semver: 7.5.4
transitivePeerDependencies:
- supports-color
+2 -7
View File
@@ -129,7 +129,7 @@ export default {
form: {
namePlaceholder: "e.g. ivan-laptop",
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",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically",
@@ -185,13 +185,11 @@ export default {
},
config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
resetTrafficCron: "Reset traffic schedule task",
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",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
invalidTrafficTime: "Field must be a number with up to one decimal place",
},
monitor: {
cpuPercent: "CPU Usage",
@@ -229,7 +227,6 @@ export default {
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
addOutbound: "Add Outbound",
extension: "Extension",
listen: "Listen",
tls: "TLS",
obfs: "Obfuscation",
@@ -245,8 +242,6 @@ export default {
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Enable/Disable",
remark: "Remark",
listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type",
+3 -8
View File
@@ -125,7 +125,7 @@ export default {
form: {
namePlaceholder: "например, ivan-laptop",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
@@ -181,13 +181,11 @@ export default {
},
config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficCronTip:
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой",
},
monitor: {
cpuPercent: "CPU",
@@ -225,7 +223,6 @@ export default {
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
addOutbound: "Добавить outbound",
extension: "Расширение",
listen: "Адрес прослушивания",
tls: "TLS",
obfs: "Маскировка",
@@ -241,8 +238,6 @@ export default {
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Включить/отключить",
remark: "Комментарий",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип 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
// We suggest you to commit this file into source control
// 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 {
ElAlert: typeof import("element-plus/es")["ElAlert"];
ElAvatar: typeof import("element-plus/es")["ElAvatar"];
ElButton: typeof import("element-plus/es")["ElButton"];
ElCard: typeof import("element-plus/es")["ElCard"];
ElCol: typeof import("element-plus/es")["ElCol"];
ElDatePicker: typeof import("element-plus/es")["ElDatePicker"];
ElDialog: typeof import("element-plus/es")["ElDialog"];
ElDrawer: typeof import("element-plus/es")["ElDrawer"];
ElDropdown: typeof import("element-plus/es")["ElDropdown"];
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"];
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"];
ElEmpty: typeof import("element-plus/es")["ElEmpty"];
ElForm: typeof import("element-plus/es")["ElForm"];
ElFormItem: typeof import("element-plus/es")["ElFormItem"];
ElInput: typeof import("element-plus/es")["ElInput"];
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"];
ElMenu: typeof import("element-plus/es")["ElMenu"];
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"];
ElOption: typeof import("element-plus/es")["ElOption"];
ElPagination: typeof import("element-plus/es")["ElPagination"];
ElProgress: typeof import("element-plus/es")["ElProgress"];
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"];
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"];
ElRow: typeof import("element-plus/es")["ElRow"];
ElScrollbar: typeof import("element-plus/es")["ElScrollbar"];
ElSelect: typeof import("element-plus/es")["ElSelect"];
ElSubMenu: typeof import("element-plus/es")["ElSubMenu"];
ElSwitch: typeof import("element-plus/es")["ElSwitch"];
ElTable: typeof import("element-plus/es")["ElTable"];
ElTableColumn: typeof import("element-plus/es")["ElTableColumn"];
ElTabPane: typeof import("element-plus/es")["ElTabPane"];
ElTabs: typeof import("element-plus/es")["ElTabs"];
ElTag: typeof import("element-plus/es")["ElTag"];
ElTooltip: typeof import("element-plus/es")["ElTooltip"];
ElUpload: typeof import("element-plus/es")["ElUpload"];
Hamburger: typeof import("./../components/Hamburger/index.vue")["default"];
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"];
IEpDownload: typeof import("~icons/ep/download")["default"];
IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"];
IEpRefresh: typeof import("~icons/ep/refresh")["default"];
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"];
IEpUpload: typeof import("~icons/ep/upload")["default"];
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"];
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"];
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"];
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"];
Pagination: typeof import("./../components/Pagination/index.vue")["default"];
RouterLink: typeof import("vue-router")["RouterLink"];
RouterView: typeof import("vue-router")["RouterView"];
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
IEpDownload: typeof import('~icons/ep/download')['default']
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
IEpRefresh: typeof import('~icons/ep/refresh')['default']
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
Pagination: typeof import('./../components/Pagination/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
}
export interface 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"
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
:content="$t('config.resetTrafficCronTip')"
placement="bottom"
@@ -79,6 +69,14 @@ export default {
//
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами.
//
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
// сбора метрик задан в коде админки, а traffic accounting его не читал.
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
// оператору управление, которого не существовало.
//
// Осталась ровно одна настройка, которой панель действительно владеет.
import { Select } from "@element-plus/icons-vue";
import { listConfigApi, updateConfigsApi } from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types";
@@ -88,7 +86,6 @@ const { t } = useI18n();
const dataFormRef = ref(ElForm);
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const cronResetTraffic = [
@@ -96,24 +93,18 @@ const cronResetTraffic = [
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
];
const dataFormRules = {
hysteria2TrafficTime: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+(\.\d)?$/,
message: t("config.invalidTrafficTime"),
trigger: ["change", "blur"],
},
],
};
// Правил формы здесь нет намеренно.
//
// Поле — el-select с allow-create, то есть значение произвольное, и
// единственная осмысленная проверка cron-выражения — разбор его тем же
// парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
// вторая, приблизительная реализация на фронтенде расходилась бы с ним и
// давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
// легально и означает «автоматический сброс выключен».
const dataFormRules = {};
const state = reactive({
dataForm: {
hysteria2TrafficTime: "1",
resetTrafficCron: "",
},
});
@@ -124,10 +115,6 @@ const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => {
if (valid) {
let configs: ConfigsUpdateDto[] = [
{
key: hysteria2TrafficTimeKey,
value: state.dataForm.hysteria2TrafficTime,
},
{
key: resetTrafficCronKey,
value: state.dataForm.resetTrafficCron,
@@ -143,13 +130,11 @@ const submitForm = () => {
const setConfig = async () => {
const { data } = await listConfigApi({
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
keys: [resetTrafficCronKey],
});
data.forEach((configVo) => {
if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === resetTrafficCronKey) {
if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value;
}
});
@@ -51,16 +51,6 @@
:disabled="true"
>
<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-tooltip
:content="$t('hysteria.config.listen')"
@@ -1062,18 +1052,19 @@ import {
Hysteria2ServerConfig,
} from "@/api/config/types";
import Outbounds from "./components/Outbounds/index.vue";
import {
exportHysteria2ConfigApi,
getHysteria2ConfigApi,
listConfigApi,
} from "@/api/config";
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
import { useI18n } from "vue-i18n";
import { assignWith, deepCopy } from "@/utils/copy";
import { dashboardSummaryApi } from "@/api/dashboard";
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 dataFormRules = {
@@ -1118,11 +1109,8 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({
configForm: {
remark: "",
},
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
activeName: "extension",
activeName: "listen",
tlsType: "acme",
aclType: "inline",
obfs: false,
@@ -1143,7 +1131,6 @@ const state = reactive({
});
const {
configForm,
activeName,
dataForm,
tlsType,
@@ -1183,17 +1170,6 @@ const handleExport = async () => {
};
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) => {
const data = response.data;
if (data) {
+45 -39
View File
@@ -1,67 +1,73 @@
module hy2xs-admin
go 1.21
// Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет
// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в
// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе
// с ней в бинарь попадала соответствующая stdlib.
go 1.25.0
require (
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/go-playground/validator/v10 v10.14.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/go-playground/validator/v10 v10.30.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/sirupsen/logrus v1.9.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.18.0
github.com/sirupsen/logrus v1.10.2
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.55.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.9
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/bytedance/gopkg v0.1.4 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // 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/spf13/pflag v1.0.5 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // 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
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.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/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
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/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.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/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/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/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.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
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.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/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/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.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
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/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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.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.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.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/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
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/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
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-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
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
// терминируется на внешнем слое. Панель записывала в 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 (
JwtSecret = "JWT_SECRET"
PeerSecretKey = "PEER_SECRET_KEY"
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
Hysteria2Enable = "HYSTERIA2_ENABLE"
Hysteria2Config = "HYSTERIA2_CONFIG"
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
ResetTrafficCron = "RESET_TRAFFIC_CRON"
)
@@ -40,20 +52,18 @@ const (
var (
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
publicReadableConfigKeys = map[string]struct{}{
Hysteria2TrafficTime: {},
ResetTrafficCron: {},
Hysteria2ConfigRemark: {},
ResetTrafficCron: {},
}
// publicWritableConfigKeys — подмножество читаемых, которые оператор
// действительно редактирует в интерфейсе.
//
// HYSTERIA2_CONFIG_REMARK сюда не входит: панель его только отображает.
// Право записи, которым никто не пользуется, — это attack surface без
// пользователя.
// После очистки мёртвых ключей оба списка совпали: единственная настройка,
// которой владеет панель, — расписание сброса счётчиков трафика. Списки
// намеренно не схлопнуты в один: читаемое и записываемое — разные права, и
// первый же ключ «показываем, но менять нельзя» вернёт разницу.
publicWritableConfigKeys = map[string]struct{}{
Hysteria2TrafficTime: {},
ResetTrafficCron: {},
ResetTrafficCron: {},
}
)
@@ -89,8 +99,6 @@ func InternalConfigKeys() []string {
PeerSecretKey,
PeerSecretEncryptionKey,
Hysteria2TrafficStatsSecret,
Hysteria2Enable,
Hysteria2Config,
}
}
+25 -12
View File
@@ -34,29 +34,42 @@ func TestSecretKeysAreListedAsInternal(t *testing.T) {
}
func TestOperatorSettingsRemainAvailable(t *testing.T) {
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron, Hysteria2ConfigRemark} {
if !IsPublicReadableConfigKey(key) {
t.Errorf("пользовательская настройка %s недоступна на чтение", key)
}
if !IsPublicReadableConfigKey(ResetTrafficCron) {
t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron)
}
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron} {
if !IsPublicWritableConfigKey(key) {
t.Errorf("пользовательская настройка %s недоступна на запись", key)
if !IsPublicWritableConfigKey(ResetTrafficCron) {
t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron)
}
}
// Ключи, удалённые как мёртвое состояние, не должны вернуться в 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) {
for _, key := range PublicWritableConfigKeys() {
if !IsPublicReadableConfigKey(key) {
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
}
}
if IsPublicWritableConfigKey(Hysteria2ConfigRemark) {
t.Error("HYSTERIA2_CONFIG_REMARK не редактируется в панели и не должен быть записываемым")
}
}
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 {
return "", false, err
}
verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash)
if !verified {
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
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)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion
@@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil {
return "", false, tokenErr
}
requirePasswordChange := legacy
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
// с поддержкой самого формата: единственный источник этого требования —
// флаг force_password_change, который ставит установка.
requirePasswordChange := false
if admin.ForcePasswordChange != nil {
requirePasswordChange = *admin.ForcePasswordChange != 0
}
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) {
claims, ok := adminClaimsFromContext(c)
if !ok {
myClaims, err := ParseToken(GetToken(c))
if err != nil {
return vo.AdminInfoVo{}, err
}
claims = myClaims.Admin
return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
}
admin, err := dao.GetAdminUser("id = ?", claims.Id)
if err != nil {
@@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
if admin.PasswordHash == nil {
return errors.New(constant.SysError)
}
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash)
if !verified {
if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
return errors.New(constant.WrongPassword)
}
hash, hashErr := util.HashPassword(newPassword)
+63 -14
View File
@@ -15,10 +15,53 @@ import (
"strings"
)
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
type ConfigUpdate struct {
Key string
Value string
}
func UpdateConfig(key string, value string) error {
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) {
return dao.GetConfig("key = ?", key)
}
@@ -27,23 +70,26 @@ func ListConfig(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key in ?", keys)
}
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
//
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
// install создавал его пустым, и пустым он оставался навсегда.
//
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
// продолжала работать по файлу. То есть ключ был готовым вторым источником
// истины — ровно тем, что проект последовательно убирает.
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
var serverConfig bo.Hysteria2ServerConfig
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
return serverConfig, err
}
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
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 {
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
return serverConfig, err
}
return serverConfig, nil
@@ -54,15 +100,18 @@ func GetHysteria2ApiPort() (int64, error) {
if err != nil {
return 0, err
}
// Сообщение передаётся аргументом, а не форматной строкой: значение
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
// запись журнала в мусор вида `%!s(MISSING)`.
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
logrus.Errorf(errMsg)
logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg)
}
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
if err != nil {
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 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 (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/util"
)
@@ -13,19 +10,19 @@ import (
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
// смены версии здесь нет намеренно, а не «временно отключены».
// InitHysteria2 проверяет единственное, что панели действительно нужно от
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
// у которого спрашивает состояние Hysteria2IsRunning.
//
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
// в которой панель сама запускала и останавливала Hysteria: clean install
// создавал его со значением "0", записать "1" было некому, а вся ветка
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
// выглядит выключателем, хуже отсутствующей.
func InitHysteria2() error {
if !util.Exists(util.GetHysteria2BinPath()) {
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
}
+22 -8
View File
@@ -148,15 +148,8 @@ func Hysteria2Url(accountId int64) (string, error) {
if err != nil {
return "", err
}
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", err
}
remark := ""
if hysteria2ConfigRemark.Value != nil {
remark = *hysteria2ConfigRemark.Value
}
remark := shareRemark(peer.Name, hostname)
obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
@@ -172,6 +165,27 @@ func Hysteria2Url(accountId int64) (string, error) {
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 перечисляет типы обфускации, которые официальная
// URI-схема Hysteria умеет передавать клиенту.
func isShareableObfsType(obfsType string) bool {
+7 -10
View File
@@ -8,7 +8,6 @@ import (
"gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
@@ -21,19 +20,17 @@ const RedactedPlaceholder = "<redacted>"
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
//
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
// Здесь у неё было дополнительное следствие — выгрузка «фактического
// серверного конфига» могла отдать оператору документ, по которому Hysteria
// никогда не работала.
func GetRawHysteria2Config() (string, error) {
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
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
}
+18
View File
@@ -4,8 +4,10 @@ import (
"bufio"
"encoding/json"
"fmt"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"os"
"strconv"
"strings"
"time"
@@ -58,6 +60,22 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
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 оператору.
//
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
+87 -36
View File
@@ -2,59 +2,110 @@ package service
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"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
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
// не должен разбираться как наш даже при совпадении ключа.
const tokenIssuer = "hy2xs-admin"
type MyClaims struct {
Admin bo.AccountBo `json:"admin"`
jwt.StandardClaims
jwt.RegisteredClaims
}
func GenToken(accountBo bo.AccountBo) (string, 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) {
func jwtSecret() ([]byte, error) {
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return nil, errors.New(constant.SysError)
}
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
return []byte(*config.Value), nil
})
if err != nil {
return nil, errors.New(constant.IllegalTokenError)
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
// молча превращался в ключ нулевой длины.
return nil, errors.New(constant.SysError)
}
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New(constant.TokenExpiredError)
return []byte(*config.Value), nil
}
func GetToken(c *gin.Context) string {
tokenStr := c.Request.Header.Get("Authorization")
if tokenStr == "" {
return ""
func GenToken(accountBo bo.AccountBo) (string, error) {
secret, err := jwtSecret()
if err != nil {
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
}
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
//
// Что было. 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) {
if peerDto.Name == nil || *peerDto.Name == "" {
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) {
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 {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
return err
}
updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
@@ -128,6 +162,47 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
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 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
import (
"encoding/base64"
"errors"
"strings"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
)
func getOrCreateConfigKey(key string, size int) (string, error) {
cfg, err := dao.GetConfig("key = ?", key)
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
return strings.TrimSpace(*cfg.Value), nil
}
raw, genErr := util.RandomString(size)
if genErr != nil {
return "", genErr
}
value := raw
remark := key
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 {
return "", updErr
}
}
return value, nil
}
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
//
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
// getPeerSecretEncryptionKey — построчные копии одноимённых функций из
// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и проявилось бы это на живых пирах.
//
// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
// из-за которого каждая первая загрузка печатала в журнал
// `duplicated key not allowed` уровня error на здоровом старте.
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) {
return getOrCreateConfigKey(constant.PeerSecretKey, 48)
}
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
return dao.GetOrCreatePeerSecretDigestKey()
}
func PeerSecretDigest(rawSecret string) (string, error) {
@@ -65,7 +35,7 @@ func PeerSecretDigest(rawSecret string) (string, error) {
}
func EncryptPeerSecret(rawSecret string) (string, error) {
key, err := getPeerSecretEncryptionKey()
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
@@ -76,7 +46,7 @@ func DecryptPeerSecret(stored string) (string, error) {
if !strings.HasPrefix(stored, "v1:") {
return stored, nil
}
key, err := getPeerSecretEncryptionKey()
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
+25 -15
View File
@@ -15,16 +15,22 @@ import (
"golang.org/x/crypto/bcrypt"
)
func SHA224String(password string) string {
hash := sha256.New224()
hash.Write([]byte(password))
val := hash.Sum(nil)
str := ""
for _, v := range val {
str += fmt.Sprintf("%02x", v)
}
return str
}
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
//
// Это был формат хранения паролей предыдущего поколения: несолёный SHA-224,
// то есть быстрый хеш общего назначения, по которому словарная атака идёт
// на порядки быстрее, чем по bcrypt, а одинаковые пароли дают одинаковые
// хеши. VerifyPassword принимал такие хеши как «legacy» и молча переписывал
// их в bcrypt при первом успешном входе.
//
// В HY2XS v1 такого хеша не может существовать. Единственные места, создающие
// учётную запись, — ensureSecureBootstrapAdmin и `reset-admin`, и оба пишут
// bcrypt; миграции 002/003, переносившие таблицу `account` предыдущего
// поколения, удалены; установка возможна только на чистый хост, а конфигурация
// 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION. То есть compatibility-ветка
// пережила ровно тот слой совместимости, ради которого существовала, и
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
// логина, доступном без аутентификации.
func HashPassword(password string) (string, error) {
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$")
}
func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) {
if IsBcryptHash(storedHash) {
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
return err == nil, false
// VerifyPassword проверяет пароль ровно против одного формата хранения.
//
// Хеш, который не является bcrypt, — это не «пароль в старом формате», а
// повреждённая или подменённая строка в базе, и правильный ответ на неё —
// отказ во входе.
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 {
+96 -3
View File
@@ -1,7 +1,100 @@
package util
import "testing"
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func TestSHA224String(t *testing.T) {
println(SHA224String("sysadmin"))
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
// проверял. Вместе с самой функцией он удалён: формат хранения паролей
// предыдущего поколения в 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
import "crypto/rand"
import (
"crypto/rand"
"errors"
)
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) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
if length <= 0 {
return "", errors.New("random string length must be positive")
}
for i := range bytes {
bytes[i] = charset[int(bytes[i])%len(charset)]
out := make([]byte, 0, length)
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)
}
}
}