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 } 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")) passwordGenerated := false if adminPassword == "" { password, pwdErr := util.RandomString(18) if pwdErr != nil { return pwdErr } adminPassword = password passwordGenerated = true } 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 } if passwordGenerated { logrus.Warnf("Initial admin username: %s", adminUser) logrus.Warnf("Initial admin password: %s", adminPassword) } return nil } 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 } if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil { return nil } 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 } func getOrCreatePeerSecretEncryptionKey() ([]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 } 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 { 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) } func getOrCreateConfigKey(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) 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 } } 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}, } 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 { // Сетевых настроек панели здесь нет: ими владеет оркестратор. defaults := map[string]string{ constant.JwtSecret: "", constant.Hysteria2Enable: "0", constant.Hysteria2Config: "", constant.Hysteria2TrafficTime: "10", constant.Hysteria2ConfigRemark: "", 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() } 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 := 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 } } 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 } 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 } 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 } 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 } 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)) } }