Files
HY2XS_flamy/apps/dao/sqlite.go
T
founder b99be7d514 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 пакета.
2026-08-29 21:37:50 +05:00

671 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dao
import (
"encoding/base64"
"errors"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
"log"
"os"
"strings"
"time"
)
var sqliteDB *gorm.DB
func InitSqliteDB() error {
return InitSqliteDBAt(constant.SqliteDBPath)
}
// InitSqliteDBAt открывает базу по указанному пути.
//
// Путь параметризован, а не зашит: без этого единственным способом проверить
// поведение слоя данных (транзакционность импорта пиров, в первую очередь)
// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db.
func InitSqliteDBAt(path string) error {
var err error
sqliteDB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
TranslateError: true,
Logger: logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Silent,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: true,
Colorful: false,
},
),
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
})
if err != nil {
logrus.Errorf("sqlite open err: %v", err)
return errors.New("sqlite open err")
}
return nil
}
// InitSql поднимает базу админки.
//
// Порт сюда больше не передаётся: раньше InitSql записывал в config тот самый
// порт, который получил аргументом из ExecStart, а startup тут же читал его
// обратно. База не была источником этого факта ни на одном шаге.
func InitSql() error {
if err := InitSqliteDB(); err != nil {
return err
}
if err := runMigrations(); err != nil {
return err
}
if err := ensureSecureBootstrapAdmin(); err != nil {
return err
}
if err := ensureSecureBootstrapPeer(); err != nil {
return err
}
if _, err := getOrCreateJwtSecret(); err != nil {
return err
}
if err := ensureTrafficStatsSecret(); err != nil {
return err
}
return nil
}
func envBoolAsInt(name string, fallback int) int {
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
if raw == "" {
return fallback
}
if raw == "true" || raw == "1" {
return 1
}
if raw == "false" || raw == "0" {
return 0
}
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 == "" {
adminUser = "hy2xsadmin"
}
if _, err := GetAdminUser("username = ?", adminUser); err == nil {
return nil
}
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
if adminPassword == "" {
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)
tokenVersion := int64(1)
passwordChangedAt := time.Now().UnixMilli()
hash, hashErr := util.HashPassword(adminPassword)
if hashErr != nil {
return hashErr
}
username := adminUser
account := entity.AdminUser{
Username: &username,
PasswordHash: &hash,
Status: &status,
TokenVersion: &tokenVersion,
PasswordChangedAt: &passwordChangedAt,
ForcePasswordChange: func() *int64 { v := int64(forcePasswordChange); return &v }(),
}
if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr
}
return nil
}
// ensureSecureBootstrapPeer создаёт пир установщика.
//
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
// файлом, и первая же проверка подключения после установки провалилась бы —
// на корректном во всём остальном сервере.
//
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
// без переменной падал бы на ровном месте.
func ensureSecureBootstrapPeer() error {
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
return nil
}
if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil {
return nil
}
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if bootstrapSecret == "" {
return errors.New(
"HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" +
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
"подключения после установки провалилась бы.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
secretDigest, err := buildPeerSecretDigest(bootstrapSecret)
if err != nil {
return err
}
if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil {
return nil
}
secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret)
if err != nil {
return err
}
name := "bootstrap-admin-peer"
authID := "bootstrap-admin-peer"
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
quota := int64(-1)
download := int64(0)
upload := int64(0)
expires := int64(0)
maxDevices := int64(3)
disabled := int64(0)
bannedUntil := int64(0)
lastConnection := int64(0)
peer := entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &secretDigest,
SecretEncrypted: &secretEncrypted,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
_, saveErr := SavePeer(peer)
return saveErr
}
func buildPeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := GetOrCreatePeerSecretDigestKey()
if err != nil {
return "", err
}
return util.HmacSHA256Hex(rawSecret, secretKey), nil
}
// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
// пиров, при необходимости создавая его.
//
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
// ними означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и обнаружилось бы это на живых пирах.
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
if err != nil {
return nil, err
}
decoded, decErr := util.DecodeBase64Key(raw, 32)
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]
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()
if err != nil {
return "", err
}
return util.EncryptAESGCM(rawSecret, key)
}
// 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
}
value, genErr := util.RandomString(size)
if genErr != nil {
return "", genErr
}
if err := UpsertConfigValue(key, value); err != nil {
return "", err
}
return value, nil
}
// RunMigrations приводит схему базы к текущему поколению.
//
// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам
// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир
// и генерация JWT-секрета.
func RunMigrations() error {
return runMigrations()
}
func runMigrations() error {
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`); tx.Error != nil {
logrus.Errorf("sqlite migration init err: %v", tx.Error)
return errors.New("sqlite migration init err")
}
type migration struct {
version string
apply func() error
}
// Миграций 002_migrate_legacy_accounts и 003_archive_legacy_account здесь
// больше нет.
//
// Они переносили таблицу `account` предыдущего поколения в admin_user/peer
// и переименовывали её в бэкап. Это compatibility-слой продукта, который
// HY2XS v1 принципиально не поддерживает: установка возможна только на
// чистый хост, база 0.x не мигрируется ни при каком сценарии, а clean-host
// контракт отказывает ещё до создания базы. Живого пути, по которому такая
// таблица могла бы оказаться в hy2xs-admin.db, не существует.
//
// Номера оставшихся миграций сохранены: перенумерация заставила бы их
// примениться повторно на уже установленных машинах.
migrations := []migration{
{version: "000_base_config", apply: migrateBaseConfig},
{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 {
if isApplied, err := migrationApplied(m.version); err != nil {
return err
} else if isApplied {
continue
}
if err := m.apply(); err != nil {
return err
}
if tx := sqliteDB.Exec("INSERT INTO schema_migrations(version) VALUES(?)", m.version); tx.Error != nil {
logrus.Errorf("sqlite migration mark err: %v", tx.Error)
return errors.New("sqlite migration mark err")
}
}
return nil
}
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.ResetTrafficCron: "0 0 * * *",
constant.Hysteria2TrafficStatsSecret: "",
constant.PeerSecretKey: "",
constant.PeerSecretEncryptionKey: "",
}
for k, v := range defaults {
if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil {
logrus.Errorf("sqlite seed config err: %v", tx.Error)
return errors.New("sqlite seed config err")
}
}
return nil
}
func migrateBaseConfig() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE DEFAULT '',
value TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS config_key_index ON config (key)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
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 {
logrus.Errorf("sqlite migration query err: %v", tx.Error)
return false, errors.New("sqlite migration query err")
}
return count > 0, nil
}
func migrateAdminPeerSplit() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
force_password_change INTEGER NOT NULL DEFAULT 0,
last_login_at INTEGER NOT NULL DEFAULT 0,
password_changed_at INTEGER NOT NULL DEFAULT 0,
token_version INTEGER NOT NULL DEFAULT 1,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS admin_user_username_index ON admin_user (username)`,
`CREATE TABLE IF NOT EXISTS peer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
auth_id TEXT NOT NULL UNIQUE DEFAULT '',
secret_digest TEXT NOT NULL UNIQUE DEFAULT '',
secret_ciphertext TEXT NOT NULL DEFAULT '',
quota_bytes INTEGER NOT NULL DEFAULT 0,
download_bytes INTEGER NOT NULL DEFAULT 0,
upload_bytes INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
max_devices INTEGER NOT NULL DEFAULT 3,
disabled INTEGER NOT NULL DEFAULT 0,
banned_until INTEGER NOT NULL DEFAULT 0,
last_connection_at INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS peer_name_index ON peer (name)`,
`CREATE INDEX IF NOT EXISTS peer_auth_id_index ON peer (auth_id)`,
`CREATE INDEX IF NOT EXISTS peer_secret_digest_index ON peer (secret_digest)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func getOrCreateJwtSecret() (string, error) {
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return strings.TrimSpace(*existing.Value), nil
}
}
secret, secErr := util.RandomString(64)
if secErr != nil {
return "", secErr
}
if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
return "", err
}
return secret, nil
}
// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
// логики жила в service/peer_secret.go под именем GetPeerSecretKey.
func GetOrCreatePeerSecretDigestKey() (string, error) {
return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
}
func migrateTrafficTables() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS traffic_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id INTEGER NOT NULL,
auth_id TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
sampled_at INTEGER NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_sample_time ON traffic_sample(sampled_at)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_sample_peer_time ON traffic_sample(peer_id, sampled_at)`,
`CREATE TABLE IF NOT EXISTS traffic_aggregate_hourly (
peer_id INTEGER NOT NULL,
hour_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(peer_id, hour_start)
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_hourly_hour_start ON traffic_aggregate_hourly(hour_start)`,
`CREATE TABLE IF NOT EXISTS traffic_aggregate_daily (
peer_id INTEGER NOT NULL,
day_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(peer_id, day_start)
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_daily_day_start ON traffic_aggregate_daily(day_start)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func migrateMetricSampleTable() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS metric_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at INTEGER NOT NULL,
cpu_percent REAL NOT NULL DEFAULT 0,
load1 REAL NOT NULL DEFAULT 0,
mem_used_bytes INTEGER NOT NULL DEFAULT 0,
mem_total_bytes INTEGER NOT NULL DEFAULT 0,
mem_percent REAL NOT NULL DEFAULT 0,
disk_path TEXT NOT NULL DEFAULT '/',
disk_used_bytes INTEGER NOT NULL DEFAULT 0,
disk_total_bytes INTEGER NOT NULL DEFAULT 0,
disk_percent REAL NOT NULL DEFAULT 0,
hysteria_running INTEGER NOT NULL DEFAULT 0,
online_peers INTEGER NOT NULL DEFAULT 0,
online_devices INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_metric_sample_time ON metric_sample(sampled_at)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func tableExists(tableName string) bool {
var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&count); tx.Error != nil {
return false
}
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 != "" {
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 UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret)
}
func CloseSqliteDB() error {
if sqliteDB != nil {
db, err := sqliteDB.DB()
if err != nil {
logrus.Errorf("sqlite err: %v", err)
return errors.New("sqlite err")
}
if err = db.Close(); err != nil {
logrus.Errorf("sqlite close err: %v", err)
return errors.New("sqlite close err")
}
}
return nil
}
func IsSqliteReady() bool {
if sqliteDB == nil {
return false
}
db, err := sqliteDB.DB()
if err != nil {
return false
}
if err := db.Ping(); err != nil {
return false
}
return true
}
func Paginate(pageNum *int64, pageSize *int64) func(db *gorm.DB) *gorm.DB {
var num int64 = 1
var size int64 = 10
if pageNum != nil && *pageNum > 0 {
num = *pageNum
}
if pageSize != nil && *pageSize > 0 {
size = *pageSize
}
return func(db *gorm.DB) *gorm.DB {
return db.Offset(int((num - 1) * size)).Limit(int(size))
}
}