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: "a, 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)) } }