diff --git a/apps/controller/admin_user.go b/apps/controller/admin_user.go index f2de848..d83c368 100644 --- a/apps/controller/admin_user.go +++ b/apps/controller/admin_user.go @@ -28,3 +28,12 @@ func AdminChangePassword(c *gin.Context) { vo.Success(nil, c) } +func AdminSecurity(c *gin.Context) { + info, err := service.GetAdminInfo(c) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(gin.H{"forcePasswordChange": info.ForcePasswordChange}, c) +} + diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index 2ce0a20..24e791e 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -2,27 +2,12 @@ package controller import ( "github.com/gin-gonic/gin" - "github.com/skip2/go-qrcode" "hy2xs-admin/model/dto" "hy2xs-admin/model/vo" "hy2xs-admin/service" - "strconv" - "strings" "time" ) -func resolvePeerID(c *gin.Context) (*int64, bool) { - raw := strings.TrimSpace(c.Param("id")) - if raw == "" { - return nil, false - } - parsed, err := strconv.ParseInt(raw, 10, 64) - if err != nil || parsed <= 0 { - return nil, false - } - return &parsed, true -} - func Hysteria2Auth(c *gin.Context) { var req dto.Hysteria2AuthDto if err := c.ShouldBindJSON(&req); err != nil { @@ -48,19 +33,6 @@ func Hysteria2Auth(c *gin.Context) { vo.Hysteria2AuthSuccess(username, c) } -func Hysteria2Kick(c *gin.Context) { - hysteria2KickDto, err := validateField(c, dto.Hysteria2KickDto{}) - if err != nil { - return - } - err = service.Hysteria2Kick(hysteria2KickDto.Ids, *hysteria2KickDto.KickUtilTime) - if err != nil { - vo.Fail(err.Error(), c) - return - } - vo.Success(nil, c) -} - func Hysteria2ChangeVersion(c *gin.Context) { vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c) } @@ -68,33 +40,3 @@ func Hysteria2ChangeVersion(c *gin.Context) { func ListRelease(c *gin.Context) { vo.Success([]string{}, c) } - -func Hysteria2Url(c *gin.Context) { - hysteria2UrlDto := dto.Hysteria2UrlDto{} - if id, ok := resolvePeerID(c); ok { - hysteria2UrlDto.AccountId = id - } else { - var err error - hysteria2UrlDto, err = validateField(c, dto.Hysteria2UrlDto{}) - if err != nil { - return - } - } - - url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId) - if err != nil { - vo.Fail(err.Error(), c) - return - } - // Генерация QR-кода - qrCode, err := qrcode.Encode(url, qrcode.Medium, 300) - if err != nil { - vo.Fail(err.Error(), c) - return - } - hysteria2UrlVo := vo.Hysteria2UrlVo{ - Url: url, - QrCode: qrCode, - } - vo.Success(hysteria2UrlVo, c) -} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index bf616a5..32dc751 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -3,19 +3,19 @@ package controller import ( "encoding/json" "fmt" + "io" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/service" "hy2xs-admin/util" - "io" - "path/filepath" - "strconv" - "strings" - "time" ) func resolveID(c *gin.Context) (int64, error) { @@ -37,18 +37,12 @@ func Login(c *gin.Context) { if err != nil { return } - token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass) if err != nil { vo.Fail(err.Error(), c) return } - jwtVo := vo.JwtVo{ - TokenType: constant.TokenType, - AccessToken: token, - ForcePasswordChange: forcePasswordChange, - } - vo.Success(jwtVo, c) + vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c) } func PagePeer(c *gin.Context) { @@ -56,85 +50,25 @@ func PagePeer(c *gin.Context) { if err != nil { return } - accounts, total, err := service.PagePeer(peerPageDto) + records, total, err := service.PagePeer(peerPageDto) if err != nil { vo.Fail(err.Error(), c) return } - - onlineUsers, err := service.Hysteria2Online() - if err != nil { - vo.Fail(err.Error(), c) - return - } - - var accountVos []vo.AccountVo - for _, item := range accounts { - accountVo := vo.AccountVo{ - Username: *item.Username, - Quota: *item.Quota, - Download: *item.Download, - Upload: *item.Upload, - ExpireTime: *item.ExpireTime, - KickUtilTime: *item.KickUtilTime, - DeviceNo: *item.DeviceNo, - Role: *item.Role, - Deleted: *item.Deleted, - BaseVo: vo.BaseVo{ - Id: *item.Id, - CreateTime: *item.CreateTime, - }, - LoginAt: *item.LoginAt, - ConAt: *item.ConAt, - Remark: *item.Remark, - } - if value, exists := onlineUsers[*item.Username]; exists { - accountVo.Online = true - accountVo.Device = value - delete(onlineUsers, *item.Username) - } - accountVos = append(accountVos, accountVo) - } - accountPageVo := vo.AccountPageVo{ - AccountVos: accountVos, - Total: total, - } - vo.Success(accountPageVo, c) + vo.Success(vo.PeerPageVo{Records: records, Total: total}, c) } func SavePeer(c *gin.Context) { - accountSaveDto, err := validateField(c, dto.AccountSaveDto{}) + peerSaveDto, err := validateField(c, dto.PeerSaveDto{}) if err != nil { return } - - if service.ExistPeerName(*accountSaveDto.Username, 0) { - vo.Fail(fmt.Sprintf("username %s already exists", *accountSaveDto.Username), c) - return - } - - passEncrypt, err := util.HashPassword(*accountSaveDto.Pass) + peerVo, err := service.CreatePeer(peerSaveDto) if err != nil { vo.Fail(err.Error(), c) return } - conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass) - account := entity.Account{ - Username: accountSaveDto.Username, - Pass: &passEncrypt, - ConPass: &conPass, - Quota: accountSaveDto.Quota, - ExpireTime: accountSaveDto.ExpireTime, - DeviceNo: accountSaveDto.DeviceNo, - Deleted: accountSaveDto.Deleted, - Remark: accountSaveDto.Remark, - } - err = service.SavePeer(account) - if err != nil { - vo.Fail(err.Error(), c) - return - } - vo.Success(nil, c) + vo.Success(peerVo, c) } func DeletePeer(c *gin.Context) { @@ -142,17 +76,7 @@ func DeletePeer(c *gin.Context) { if err != nil { return } - account, err := service.GetPeer(id) - if err != nil { - vo.Fail(err.Error(), c) - return - } - if *account.Role == "admin" { - vo.Fail("admin cannot be deleted", c) - return - } - err = service.DeletePeer([]int64{id}) - if err != nil { + if err = service.DeletePeer(id); err != nil { vo.Fail(err.Error(), c) return } @@ -160,105 +84,19 @@ func DeletePeer(c *gin.Context) { } func UpdatePeer(c *gin.Context) { - accountUpdateDto, err := validateField(c, dto.AccountUpdateDto{}) + id, err := resolveID(c) if err != nil { - // PATCH /peers/:id compatibility flow: id in route param, payload without id - if c.Request.Method == "PATCH" && strings.TrimSpace(c.Param("id")) != "" { - id, parseErr := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64) - if parseErr != nil || id <= 0 { - vo.Fail(constant.InvalidError, c) - return - } - var body map[string]interface{} - if bindErr := c.ShouldBindJSON(&body); bindErr != nil { - vo.Fail(constant.InvalidError, c) - return - } - legacy := entity.Account{BaseEntity: entity.BaseEntity{Id: &id}} - if v, ok := body["username"].(string); ok { - legacy.Username = &v - } - if v, ok := body["pass"].(string); ok && strings.TrimSpace(v) != "" { - hash, hashErr := util.HashPassword(v) - if hashErr != nil { - vo.Fail(hashErr.Error(), c) - return - } - legacy.Pass = &hash - } - if v, ok := body["conPass"].(string); ok { - legacy.ConPass = &v - } - if v, ok := body["quota"].(float64); ok { - t := int64(v) - legacy.Quota = &t - } - if v, ok := body["expireTime"].(float64); ok { - t := int64(v) - legacy.ExpireTime = &t - } - if v, ok := body["deviceNo"].(float64); ok { - t := int64(v) - legacy.DeviceNo = &t - } - if v, ok := body["deleted"].(float64); ok { - t := int64(v) - legacy.Deleted = &t - } - if v, ok := body["remark"].(string); ok { - legacy.Remark = &v - } - if uErr := service.UpdatePeer(legacy); uErr != nil { - vo.Fail(uErr.Error(), c) - return - } - vo.Success(nil, c) - return - } return } - - if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistPeerName(*accountUpdateDto.Username, *accountUpdateDto.Id) { - vo.Fail(fmt.Sprintf("username %s already exists", *accountUpdateDto.Username), c) + peerUpdateDto, err := validateField(c, dto.PeerUpdateDto{}) + if err != nil { return } - - if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 { - account, err := service.GetPeer(*accountUpdateDto.Id) - if err != nil { - vo.Fail(err.Error(), c) - return - } - if *account.Role == "admin" { - vo.Fail("the admin account cannot be deleted", c) - return - } + if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) { + vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) + return } - - var passEncrypt *string - if accountUpdateDto.Pass != nil && *accountUpdateDto.Pass != "" { - passEncryptHash, hashErr := util.HashPassword(*accountUpdateDto.Pass) - if hashErr != nil { - vo.Fail(hashErr.Error(), c) - return - } - passEncrypt = &passEncryptHash - } - - account := entity.Account{ - Username: accountUpdateDto.Username, - Pass: passEncrypt, - ConPass: accountUpdateDto.ConPass, - Quota: accountUpdateDto.Quota, - ExpireTime: accountUpdateDto.ExpireTime, - DeviceNo: accountUpdateDto.DeviceNo, - Deleted: accountUpdateDto.Deleted, - Remark: accountUpdateDto.Remark, - BaseEntity: entity.BaseEntity{ - Id: accountUpdateDto.Id, - }, - } - if err = service.UpdatePeer(account); err != nil { + if err = service.UpdatePeer(id, peerUpdateDto); err != nil { vo.Fail(err.Error(), c) return } @@ -270,54 +108,24 @@ func ResetTraffic(c *gin.Context) { if err != nil { return } - if err = service.ResetTraffic(id); err != nil { + if err = service.ResetPeerTraffic(id); err != nil { vo.Fail(err.Error(), c) return } vo.Success(nil, c) } -func GetAdminInfo(c *gin.Context) { - accountInfoVo, err := service.GetAdminInfo(c) - if err != nil { - vo.Fail(err.Error(), c) - return - } - // Обновление времени последнего входа - now := time.Now().UnixMilli() - if err = service.UpdateAdminLastLoginAt(accountInfoVo.Id, now); err != nil { - vo.Fail(err.Error(), c) - return - } - vo.Success(accountInfoVo, c) -} - func GetPeer(c *gin.Context) { id, err := resolveID(c) if err != nil { return } - account, err := service.GetPeer(id) + peer, err := service.GetPeerVo(id) if err != nil { vo.Fail(err.Error(), c) return } - accountVo := vo.AccountVo{ - BaseVo: vo.BaseVo{ - Id: *account.Id, - CreateTime: *account.CreateTime, - }, - Username: *account.Username, - Quota: *account.Quota, - Download: *account.Download, - Upload: *account.Upload, - ExpireTime: *account.ExpireTime, - DeviceNo: *account.DeviceNo, - Role: *account.Role, - Deleted: *account.Deleted, - Remark: *account.Remark, - } - vo.Success(accountVo, c) + vo.Success(peer, c) } func ImportPeer(c *gin.Context) { @@ -326,12 +134,10 @@ func ImportPeer(c *gin.Context) { vo.Fail(constant.SysError, c) return } - // Размер файла 2 MB if header.Size > 1024*1024*2 { vo.Fail("the file is too big", c) return } - // Расширение файла .json if !strings.HasSuffix(header.Filename, ".json") { vo.Fail(constant.InvalidError, c) return @@ -346,7 +152,7 @@ func ImportPeer(c *gin.Context) { vo.Fail("content Unmarshal err", c) return } - if err = service.UpsertPeer(accounts); err != nil { + if err = service.UpsertPeerLegacy(accounts); err != nil { vo.Fail(err.Error(), c) return } @@ -354,21 +160,18 @@ func ImportPeer(c *gin.Context) { } func ExportPeer(c *gin.Context) { - accountExports, err := service.ListExportPeer() + includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true") + peerExports, err := service.ListExportPeer(includeSecrets) if err != nil { vo.Fail(err.Error(), c) return } - - fileName := fmt.Sprintf("AccountExport-%s.json", time.Now().Format("20060102150405")) + fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405")) filePath := filepath.Join(constant.ExportPathDir, fileName) - - if err = util.ExportFile(filePath, accountExports, 0); err != nil { + if err = util.ExportFile(filePath, peerExports, 0); err != nil { vo.Fail(err.Error(), c) return } - - // Скачивание if !util.Exists(filePath) { vo.Fail("file not exist", c) return @@ -385,23 +188,38 @@ func ReleaseKickPeer(c *gin.Context) { return } if err = service.ReleaseKickPeer(id); err != nil { - logrus.Debugf("release kick err: %v", err) vo.Fail(err.Error(), c) return } vo.Success(nil, c) } -func VerifyDefaultPass(c *gin.Context) { - info, err := service.GetAdminInfo(c) +func KickPeer(c *gin.Context) { + id, err := resolveID(c) if err != nil { + return + } + kickDto, err := validateField(c, dto.PeerKickDto{}) + if err != nil { + return + } + if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil { vo.Fail(err.Error(), c) return } - admin, err := service.GetAdminAccount(info.Id) - if err != nil { - vo.Fail(err.Error(), c) - return - } - vo.Success(admin.PasswordHash != nil && !util.IsBcryptHash(*admin.PasswordHash), c) + vo.Success(nil, c) } + +func PeerClientConfig(c *gin.Context) { + id, err := resolveID(c) + if err != nil { + return + } + data, err := service.BuildPeerClientConfig(id) + if err != nil { + vo.Fail(err.Error(), c) + return + } + vo.Success(data, c) +} + diff --git a/apps/dao/peer.go b/apps/dao/peer.go index 1f49b40..80a0cec 100644 --- a/apps/dao/peer.go +++ b/apps/dao/peer.go @@ -64,11 +64,11 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) { var peers []entity.Peer var total int64 tx := sqliteDB.Model(&entity.Peer{}) - if peerPageDto.Username != nil && *peerPageDto.Username != "" { - tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Username)) + if peerPageDto.Name != nil && *peerPageDto.Name != "" { + tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Name)) } - if peerPageDto.Deleted != nil { - tx.Where("disabled = ?", *peerPageDto.Deleted) + if peerPageDto.Disabled != nil { + tx.Where("disabled = ?", *peerPageDto.Disabled) } if peerPageDto.Remark != nil && *peerPageDto.Remark != "" { tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Remark)) diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 843d15c..8b0c6da 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -105,6 +105,8 @@ func ensureSecureBootstrapAdmin() error { return pwdErr } adminPassword = password + logrus.Warnf("Initial admin username: %s", adminUser) + logrus.Warnf("Initial admin password: %s", adminPassword) } forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) status := int64(1) @@ -153,6 +155,7 @@ func runMigrations() error { } migrations := []migration{ + {version: "000_base_config", apply: migrateBaseConfig}, {version: "001_admin_peer_split", apply: migrateAdminPeerSplit}, {version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts}, {version: "003_archive_legacy_account", apply: archiveLegacyAccount}, @@ -178,6 +181,52 @@ func runMigrations() error { return nil } +func seedBaseConfig() error { + defaults := map[string]string{ + constant.HUIWebPort: "8080", + constant.HUIWebContext: "/", + constant.HUICrtPath: "", + constant.HUIKeyPath: "", + 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 { @@ -289,7 +338,7 @@ func migrateLegacyAccounts() error { return authErr } secretDigest := util.PeerSecretDigest(*acc.ConPass) - secretCiphertext := *acc.ConPass + secretEncrypted := *acc.ConPass quota := int64(0) if acc.Quota != nil { quota = *acc.Quota @@ -331,7 +380,7 @@ func migrateLegacyAccounts() error { Remark: &remark, AuthId: &authId, SecretDigest: &secretDigest, - SecretCiphertext: &secretCiphertext, + SecretEncrypted: &secretEncrypted, QuotaBytes: "a, DownloadBytes: &download, UploadBytes: &upload, diff --git a/apps/docs/sql/h_ui_db.sql b/apps/docs/sql/h_ui_db.sql index d7ff946..0edac6b 100644 --- a/apps/docs/sql/h_ui_db.sql +++ b/apps/docs/sql/h_ui_db.sql @@ -1,70 +1,108 @@ -CREATE TABLE IF NOT EXISTS account -( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE DEFAULT '', - pass TEXT NOT NULL DEFAULT '', - con_pass TEXT NOT NULL DEFAULT '', - quota INTEGER NOT NULL DEFAULT 0, - download INTEGER NOT NULL DEFAULT 0, - upload INTEGER NOT NULL DEFAULT 0, - expire_time INTEGER NOT NULL DEFAULT 0, - kick_util_time INTEGER NOT NULL DEFAULT 0, - device_no INTEGER NOT NULL DEFAULT 3, - role TEXT NOT NULL DEFAULT 'user', - deleted INTEGER NOT NULL DEFAULT 0, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +-- Reference schema only. Runtime schema is managed by dao/sqlite.go migrations. + +CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -ALTER TABLE account - ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0; -ALTER TABLE account - ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0; -ALTER TABLE account - ADD COLUMN remark INTEGER NOT NULL DEFAULT ''; -CREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted); -CREATE INDEX IF NOT EXISTS account_username_index ON account (username); -CREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass); -CREATE INDEX IF NOT EXISTS account_pass_index ON account (pass); -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 TABLE 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); -INSERT INTO config (key, value, remark) -SELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT'); -INSERT INTO config (key, value, remark) -SELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT'); -INSERT INTO config (key, value, remark) -SELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH'); -INSERT INTO config (key, value, remark) -SELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH'); -INSERT INTO config (key, value, remark) -SELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET'); -INSERT INTO config (key, value, remark) -SELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE'); -INSERT INTO config (key, value, remark) -SELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG'); -INSERT INTO config (key, value, remark) -SELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME'); -INSERT INTO config (key, value, remark) -SELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK'); -INSERT INTO config (key, value, remark) -SELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON'); -INSERT INTO config (key, value, remark) -SELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension' - WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION'); +CREATE INDEX config_key_index ON config (key); + +CREATE TABLE 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 admin_user_username_index ON admin_user (username); + +CREATE TABLE 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 peer_name_index ON peer (name); +CREATE INDEX peer_auth_id_index ON peer (auth_id); +CREATE INDEX peer_secret_digest_index ON peer (secret_digest); + +CREATE TABLE 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 idx_traffic_sample_time ON traffic_sample(sampled_at); +CREATE INDEX idx_traffic_sample_peer_time ON traffic_sample(peer_id, sampled_at); + +CREATE TABLE 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 idx_traffic_hourly_hour_start ON traffic_aggregate_hourly(hour_start); + +CREATE TABLE 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 idx_traffic_daily_day_start ON traffic_aggregate_daily(day_start); + +CREATE TABLE 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 idx_metric_sample_time ON metric_sample(sampled_at); + diff --git a/apps/frontend/src/api/admin/index.ts b/apps/frontend/src/api/admin/index.ts index f21ac24..8318e8a 100644 --- a/apps/frontend/src/api/admin/index.ts +++ b/apps/frontend/src/api/admin/index.ts @@ -16,11 +16,3 @@ export function getAdminInfoApi(): AxiosPromise { method: "get", }); } - -export function verifyDefaultPassApi(): AxiosPromise { - return request({ - url: "/admin/verify-default-pass", - method: "get", - }); -} - diff --git a/apps/frontend/src/api/hysteria2/index.ts b/apps/frontend/src/api/hysteria2/index.ts deleted file mode 100644 index 59fe44d..0000000 --- a/apps/frontend/src/api/hysteria2/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AxiosPromise } from "axios"; -import { Hysteria2ServerConfig } from "@/api/config/types"; -import request from "@/utils/request"; -import { - Hysteria2KickDto, - Hysteria2UrlDto, - Hysteria2UrlVo, -} from "@/api/hysteria2/types"; - -export function hysteria2KickApi( - data: Hysteria2KickDto -): AxiosPromise { - return request({ - url: "/hysteria2/hysteria2Kick", - method: "post", - data: data, - }); -} - -export function hysteria2UrlApi( - dto: Hysteria2UrlDto -): AxiosPromise { - return request({ - url: "/hysteria2/hysteria2Url", - method: "get", - params: dto, - }); -} - - diff --git a/apps/frontend/src/api/hysteria2/types.ts b/apps/frontend/src/api/hysteria2/types.ts deleted file mode 100644 index 0c95dd0..0000000 --- a/apps/frontend/src/api/hysteria2/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Hysteria2KickDto { - ids: number[]; - kickUtilTime: number; -} - -export interface Hysteria2UrlDto { - accountId: number; -} - -export interface Hysteria2UrlVo { - url: string; - qrCode: string; -} - diff --git a/apps/frontend/src/api/peer/index.ts b/apps/frontend/src/api/peer/index.ts index 2f7fb60..c69d81a 100644 --- a/apps/frontend/src/api/peer/index.ts +++ b/apps/frontend/src/api/peer/index.ts @@ -1,6 +1,8 @@ import request from "@/utils/request"; import { AxiosPromise } from "axios"; import { + KickPeerDto, + PeerClientConfigVo, PeerPageDto, PeerSaveDto, PeerUpdateDto, @@ -59,6 +61,23 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise { }); } +export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise { + return request({ + url: `/peers/${id}/kick`, + method: "post", + data, + }); +} + +export function getPeerClientConfigApi( + id: number +): AxiosPromise { + return request({ + url: `/peers/${id}/client-config`, + method: "get", + }); +} + export function importPeerApi(data: FormData): AxiosPromise { return request({ url: "/peers/import", diff --git a/apps/frontend/src/api/peer/types.ts b/apps/frontend/src/api/peer/types.ts index 956b4fb..327b825 100644 --- a/apps/frontend/src/api/peer/types.ts +++ b/apps/frontend/src/api/peer/types.ts @@ -1,62 +1,51 @@ export interface PeerPageDto extends BaseDto { - username?: string; - deleted?: number; + name?: string; + disabled?: number; remark?: string; } export interface PeerUpdateDto extends IdDto { - username: string; - pass: string; - conPass: string; - quota: number; - expireTime: number; - deviceNo: number; - deleted: number; - remark: string; + name?: string; + secret?: string; + quotaBytes?: number; + expiresAt?: number; + maxDevices?: number; + disabled?: number; + remark?: string; } export interface PeerSaveDto { - username: string; - pass: string; - conPass: string; - quota: number; - expireTime: number; - deviceNo: number; - deleted: number; + name: string; + secret?: string; + quotaBytes: number; + expiresAt: number; + maxDevices: number; + disabled: number; remark: string; } export interface PeerVo extends IdDto { - username: string; - quota: number; - download: number; - upload: number; - expireTime: number; - kickUtilTime: number; - deviceNo: number; - role: string; - deleted: number; + name: string; + remark: string; + authId: string; + quotaBytes: number; + downloadBytes: number; + uploadBytes: number; + expiresAt: number; + maxDevices: number; + disabled: number; + bannedUntil: number; + lastConnectionAt: number; createTime: string; online: boolean; - device: number; - loginAt: number; - conAt: number; - remark: string; + onlineDevices: number; } -export interface PeerForm extends IdDto { - username: string; - pass: string; - conPass: string; - quota: number; - expireTime: number; - deviceNo: number; - deleted: number; - remark: string; +export interface PeerClientConfigVo { + url: string; + qrCode: string | Uint8Array; } -export interface KickPeerForm { - ids: number[]; - kickUtilTime: number; +export interface KickPeerDto { + bannedUntil: number; } - diff --git a/apps/frontend/src/types/components.d.ts b/apps/frontend/src/types/components.d.ts index f048e95..8ba1500 100644 --- a/apps/frontend/src/types/components.d.ts +++ b/apps/frontend/src/types/components.d.ts @@ -26,9 +26,6 @@ declare module '@vue/runtime-core' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] - ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] - ElRadio: typeof import('element-plus/es')['ElRadio'] - 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'] diff --git a/apps/frontend/src/types/env.d.ts b/apps/frontend/src/types/env.d.ts index 7196a6b..7a85b15 100644 --- a/apps/frontend/src/types/env.d.ts +++ b/apps/frontend/src/types/env.d.ts @@ -8,8 +8,7 @@ declare module "*.vue" { } // TypeScript-подсказки для переменных окружения -interface ImportMetaEnv { -} +type ImportMetaEnv = Record; interface ImportMeta { readonly env: ImportMetaEnv; diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index 2cd62e4..7a610d7 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -1,271 +1,46 @@ - - diff --git a/apps/model/bo/account.go b/apps/model/bo/account.go index b04b911..b682289 100644 --- a/apps/model/bo/account.go +++ b/apps/model/bo/account.go @@ -3,10 +3,11 @@ package bo import "time" type AccountBo struct { - Id int64 `json:"id"` - Username string `json:"username"` - Roles []string `json:"roles"` - Deleted int64 `json:"deleted"` + Id int64 `json:"id"` + Username string `json:"username"` + Roles []string `json:"roles"` + Deleted int64 `json:"deleted"` + TokenVersion int64 `json:"tokenVersion"` } type AccountExport struct { @@ -28,3 +29,18 @@ type AccountExport struct { ConAt int64 `json:"conAt"` Remark string `json:"remark"` } + +type PeerExport struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name"` + Remark string `json:"remark"` + Secret string `json:"secret,omitempty"` + QuotaBytes int64 `json:"quotaBytes"` + DownloadBytes int64 `json:"downloadBytes,omitempty"` + UploadBytes int64 `json:"uploadBytes,omitempty"` + ExpiresAt int64 `json:"expiresAt"` + MaxDevices int64 `json:"maxDevices"` + Disabled int64 `json:"disabled"` + BannedUntil int64 `json:"bannedUntil,omitempty"` + LastConnectionAt int64 `json:"lastConnectionAt,omitempty"` +} diff --git a/apps/model/bo/subscribe.go b/apps/model/bo/subscribe.go deleted file mode 100644 index 7b286d5..0000000 --- a/apps/model/bo/subscribe.go +++ /dev/null @@ -1,27 +0,0 @@ -package bo - -type Hysteria2 struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Server string `yaml:"server"` - Port string `yaml:"port"` - Ports string `yaml:"ports,omitempty"` - Password string `yaml:"password"` - Up string `yaml:"up,omitempty"` - Down string `yaml:"down,omitempty"` - Obfs string `yaml:"obfs,omitempty"` - ObfsPassword string `yaml:"obfs-password,omitempty"` - Sni string `yaml:"sni,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` -} - -type ProxyGroup struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Proxies []string `yaml:"proxies"` -} - -type ClashConfig struct { - Proxies []interface{} `yaml:"proxies"` - ProxyGroups []ProxyGroup `yaml:"proxy-groups"` -} diff --git a/apps/model/constant/client.go b/apps/model/constant/client.go deleted file mode 100644 index a7b3697..0000000 --- a/apps/model/constant/client.go +++ /dev/null @@ -1,8 +0,0 @@ -package constant - -const ( - Shadowrocket = "shadowrocket" - Clash = "clash" - V2rayN = "v2rayn" - NekoBox = "nekobox" -) diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go index 705aa62..0b20276 100644 --- a/apps/model/constant/config.go +++ b/apps/model/constant/config.go @@ -6,6 +6,8 @@ const ( HUICrtPath = "H_UI_CRT_PATH" HUIKeyPath = "H_UI_KEY_PATH" JwtSecret = "JWT_SECRET" + PeerSecretKey = "PEER_SECRET_KEY" + PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET" Hysteria2Enable = "HYSTERIA2_ENABLE" Hysteria2Config = "HYSTERIA2_CONFIG" diff --git a/apps/model/dto/account.go b/apps/model/dto/account.go deleted file mode 100644 index ffec913..0000000 --- a/apps/model/dto/account.go +++ /dev/null @@ -1,36 +0,0 @@ -package dto - -type PeerPageDto struct { - BaseDto - Username *string `json:"username" form:"username" validate:"omitempty,min=1,max=32"` - Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` - Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` -} - -type LoginDto struct { - Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` - Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` -} - -type AccountSaveDto struct { - Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` - Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` - ConPass *string `json:"conPass" form:"conPass" validate:"required,min=6,max=32,validateStr"` - Quota *int64 `json:"quota" form:"quota" validate:"required,min=-1"` - ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"required,min=0"` - DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"required,min=1"` - Deleted *int64 `json:"deleted" form:"deleted" validate:"required,oneof=0 1"` - Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` -} - -type AccountUpdateDto struct { - IdDto - Username *string `json:"username" form:"username" validate:"omitempty,min=6,max=32,validateStr"` - Pass *string `json:"pass" form:"pass" validate:"omitempty,min=6,max=32,validateStr"` - ConPass *string `json:"conPass" form:"conPass" validate:"omitempty,min=6,max=32,validateStr"` - Quota *int64 `json:"quota" form:"quota" validate:"omitempty,min=-1"` - ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"omitempty,min=0"` - DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"omitempty,min=1"` - Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` - Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=32"` -} diff --git a/apps/model/dto/auth.go b/apps/model/dto/auth.go new file mode 100644 index 0000000..aa5f9ea --- /dev/null +++ b/apps/model/dto/auth.go @@ -0,0 +1,7 @@ +package dto + +type LoginDto struct { + Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` + Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"` +} + diff --git a/apps/model/dto/hysteria2.go b/apps/model/dto/hysteria2.go index 8eb0c88..4a16c4e 100644 --- a/apps/model/dto/hysteria2.go +++ b/apps/model/dto/hysteria2.go @@ -6,15 +6,6 @@ type Hysteria2AuthDto struct { Tx *int64 `json:"tx" form:"tx" validate:"required"` } -type Hysteria2KickDto struct { - Ids []int64 `json:"ids" form:"ids" validate:"required"` - KickUtilTime *int64 `json:"kickUtilTime" form:"kickUtilTime" validate:"required"` // Время снятия блокировки -} - type Hysteria2VersionDto struct { Version *string `json:"version" form:"version" validate:"required,min=1,max=10"` } - -type Hysteria2UrlDto struct { - AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` -} diff --git a/apps/model/dto/peer.go b/apps/model/dto/peer.go new file mode 100644 index 0000000..a30341c --- /dev/null +++ b/apps/model/dto/peer.go @@ -0,0 +1,34 @@ +package dto + +type PeerPageDto struct { + BaseDto + Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32"` + Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` +} + +type PeerSaveDto struct { + Name *string `json:"name" form:"name" validate:"required,min=1,max=32,validateStr"` + Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"` + QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"` + ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"` + MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"` + Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` +} + +type PeerUpdateDto struct { + IdDto + Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32,validateStr"` + Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"` + QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"` + ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"` + MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"` + Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"` + Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"` +} + +type PeerKickDto struct { + BannedUntil *int64 `json:"bannedUntil" form:"bannedUntil" validate:"required,min=0"` +} + diff --git a/apps/model/entity/peer.go b/apps/model/entity/peer.go index a57f813..6e6c691 100644 --- a/apps/model/entity/peer.go +++ b/apps/model/entity/peer.go @@ -5,7 +5,7 @@ type Peer struct { Remark *string `gorm:"column:remark;default:''" json:"remark"` AuthId *string `gorm:"column:auth_id;default:''" json:"authId"` SecretDigest *string `gorm:"column:secret_digest;default:''" json:"secretDigest"` - SecretCiphertext *string `gorm:"column:secret_ciphertext;default:''" json:"secretCiphertext"` + SecretEncrypted *string `gorm:"column:secret_ciphertext;default:''" json:"-"` QuotaBytes *int64 `gorm:"column:quota_bytes;default:0" json:"quotaBytes"` DownloadBytes *int64 `gorm:"column:download_bytes;default:0" json:"downloadBytes"` UploadBytes *int64 `gorm:"column:upload_bytes;default:0" json:"uploadBytes"` diff --git a/apps/model/vo/account.go b/apps/model/vo/account.go deleted file mode 100644 index 415a5e6..0000000 --- a/apps/model/vo/account.go +++ /dev/null @@ -1,31 +0,0 @@ -package vo - -type AccountVo struct { - BaseVo - Username string `json:"username"` - Quota int64 `json:"quota"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - ExpireTime int64 `json:"expireTime"` - KickUtilTime int64 `json:"kickUtilTime"` // Offline remaining time - DeviceNo int64 `json:"deviceNo"` // Limit the number of devices - Role string `json:"role"` - Deleted int64 `json:"deleted"` - - Online bool `json:"online"` // online status - Device int64 `json:"device"` // Number of online devices - - LoginAt int64 `json:"loginAt"` - ConAt int64 `json:"conAt"` - Remark string `json:"remark"` -} -type AccountPageVo struct { - AccountVos []AccountVo `json:"records"` - Total int64 `json:"total"` -} - -type AccountInfoVo struct { - Id int64 `json:"id"` - Username string `json:"username"` - Roles []string `json:"roles"` -} diff --git a/apps/model/vo/admin_user.go b/apps/model/vo/admin_user.go new file mode 100644 index 0000000..8c0a5cc --- /dev/null +++ b/apps/model/vo/admin_user.go @@ -0,0 +1,9 @@ +package vo + +type AdminInfoVo struct { + Id int64 `json:"id"` + Username string `json:"username"` + Roles []string `json:"roles"` + ForcePasswordChange bool `json:"forcePasswordChange"` +} + diff --git a/apps/model/vo/peer.go b/apps/model/vo/peer.go new file mode 100644 index 0000000..44ed449 --- /dev/null +++ b/apps/model/vo/peer.go @@ -0,0 +1,30 @@ +package vo + +type PeerVo struct { + BaseVo + Name string `json:"name"` + Remark string `json:"remark"` + AuthId string `json:"authId"` + QuotaBytes int64 `json:"quotaBytes"` + DownloadBytes int64 `json:"downloadBytes"` + UploadBytes int64 `json:"uploadBytes"` + ExpiresAt int64 `json:"expiresAt"` + MaxDevices int64 `json:"maxDevices"` + Disabled int64 `json:"disabled"` + BannedUntil int64 `json:"bannedUntil"` + LastConnectionAt int64 `json:"lastConnectionAt"` + + Online bool `json:"online"` + OnlineDevices int64 `json:"onlineDevices"` +} + +type PeerPageVo struct { + Records []PeerVo `json:"records"` + Total int64 `json:"total"` +} + +type PeerClientConfigVo struct { + Url string `json:"url"` + QrCode []byte `json:"qrCode"` +} + diff --git a/apps/router/admin_user.go b/apps/router/admin_user.go index 4fb5f54..86ae7f7 100644 --- a/apps/router/admin_user.go +++ b/apps/router/admin_user.go @@ -10,7 +10,7 @@ func initAdminRouter(adminApi *gin.RouterGroup) { { admin.GET("/me", controller.AdminMe) admin.POST("/change-password", controller.AdminChangePassword) - admin.GET("/verify-default-pass", controller.VerifyDefaultPass) + admin.GET("/security", controller.AdminSecurity) } } diff --git a/apps/router/hysteria2.go b/apps/router/hysteria2.go index a0ed5c5..bfe3abd 100644 --- a/apps/router/hysteria2.go +++ b/apps/router/hysteria2.go @@ -15,9 +15,7 @@ func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) { func initHysteria2Router(hysteria2Api *gin.RouterGroup) { hysteria2 := hysteria2Api.Group("/hysteria2") { - hysteria2.POST("/hysteria2Kick", controller.Hysteria2Kick) hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion) hysteria2.GET("/listRelease", controller.ListRelease) - hysteria2.GET("/hysteria2Url", controller.Hysteria2Url) } } diff --git a/apps/router/peer.go b/apps/router/peer.go index 6224bfe..939b076 100644 --- a/apps/router/peer.go +++ b/apps/router/peer.go @@ -16,12 +16,13 @@ func initPeerRouter(peerApi *gin.RouterGroup) { peers.POST("/:id/reset-traffic", controller.ResetTraffic) peers.POST("/:id/release-kick", controller.ReleaseKickPeer) - peers.POST("/:id/kick", controller.Hysteria2Kick) + peers.POST("/:id/kick", controller.KickPeer) + peers.GET("/:id/client-config", controller.PeerClientConfig) peers.POST("/import", controller.ImportPeer) peers.POST("/export", controller.ExportPeer) - // совместимость с текущим transport-форматом (query accountId) - peers.GET("/:id/client-url", controller.Hysteria2Url) - peers.GET("/:id/qr", controller.Hysteria2Url) + // aliases на один миграционный релиз + peers.GET("/:id/client-url", controller.PeerClientConfig) + peers.GET("/:id/qr", controller.PeerClientConfig) } } diff --git a/apps/service/admin_user.go b/apps/service/admin_user.go new file mode 100644 index 0000000..150ed97 --- /dev/null +++ b/apps/service/admin_user.go @@ -0,0 +1,98 @@ +package service + +import ( + "errors" + "time" + + "github.com/gin-gonic/gin" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" + "hy2xs-admin/model/vo" + "hy2xs-admin/util" +) + +func Login(username string, plainPassword string) (string, bool, error) { + admin, err := dao.GetAdminUser("username = ? and status = 1", username) + if err != nil { + return "", false, err + } + verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash) + if !verified { + 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 + } + accountBo := bo.AccountBo{Id: *admin.Id, Username: *admin.Username, Roles: []string{"admin"}, Deleted: 0, TokenVersion: tokenVersion} + token, tokenErr := GenToken(accountBo) + if tokenErr != nil { + return "", false, tokenErr + } + requirePasswordChange := legacy + if admin.ForcePasswordChange != nil { + requirePasswordChange = *admin.ForcePasswordChange != 0 + } + return token, requirePasswordChange, nil +} + +func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) { + myClaims, err := ParseToken(GetToken(c)) + if err != nil { + return vo.AdminInfoVo{}, err + } + admin, err := dao.GetAdminUser("id = ?", myClaims.Admin.Id) + if err != nil { + return vo.AdminInfoVo{}, err + } + if admin.Status != nil && *admin.Status == 0 { + return vo.AdminInfoVo{}, errors.New("this account has been disabled") + } + force := admin.ForcePasswordChange != nil && *admin.ForcePasswordChange != 0 + return vo.AdminInfoVo{Id: myClaims.Admin.Id, Username: myClaims.Admin.Username, Roles: myClaims.Admin.Roles, ForcePasswordChange: force}, nil +} + +func UpdateAdminLastLoginAt(id int64, loginAt int64) error { + return dao.UpdateAdminUser([]int64{id}, map[string]interface{}{"last_login_at": loginAt}) +} + +func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string) error { + info, err := GetAdminInfo(c) + if err != nil { + return err + } + admin, err := dao.GetAdminUser("id = ?", info.Id) + if err != nil { + return err + } + if admin.PasswordHash == nil { + return errors.New(constant.SysError) + } + verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash) + if !verified { + return errors.New(constant.WrongPassword) + } + hash, hashErr := util.HashPassword(newPassword) + if hashErr != nil { + return hashErr + } + nowMs := time.Now().UnixMilli() + currentTokenVersion := int64(1) + if admin.TokenVersion != nil && *admin.TokenVersion > 0 { + currentTokenVersion = *admin.TokenVersion + } + return dao.UpdateAdminUser([]int64{info.Id}, map[string]interface{}{ + "password_hash": hash, + "force_password_change": 0, + "password_changed_at": nowMs, + "token_version": currentTokenVersion + 1, + }) +} + diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go index 79e40ca..c45cea1 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -146,7 +146,15 @@ func Hysteria2Url(accountId int64) (string, error) { sni = hysteria2Config.ACME.Domains[0] } - return buildHysteria2Url(*peer.SecretCiphertext, hostname, port, obfsType, obfsPassword, sni, remark), nil + secret := "" + if peer.SecretEncrypted != nil { + decrypted, decErr := DecryptPeerSecret(*peer.SecretEncrypted) + if decErr != nil { + return "", decErr + } + secret = decrypted + } + return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil } func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string { diff --git a/apps/service/peer.go b/apps/service/peer.go index fe01f8e..9775257 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -3,7 +3,8 @@ package service import ( "errors" "fmt" - "github.com/gin-gonic/gin" + + "github.com/skip2/go-qrcode" "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" @@ -11,311 +12,241 @@ import ( "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/util" - "time" ) -func Login(username string, plainPassword string) (string, bool, error) { - account, err := dao.GetAdminUser("username = ? and status = 1", username) - if err != nil { - return "", false, err - } - - verified, legacy := util.VerifyPassword(plainPassword, *account.PasswordHash) - if !verified { - return "", false, errors.New(constant.WrongPassword) - } - - if legacy { - hash, hashErr := util.HashPassword(plainPassword) - if hashErr == nil { - _ = dao.UpdateAdminUser([]int64{*account.Id}, map[string]interface{}{"password_hash": hash}) - } - } - - accountBo := bo.AccountBo{ - Id: *account.Id, - Username: *account.Username, - Roles: []string{"admin"}, - Deleted: 0, - } - token, tokenErr := GenToken(accountBo) - if tokenErr != nil { - return "", false, tokenErr - } - - requirePasswordChange := legacy - if account.ForcePasswordChange != nil { - requirePasswordChange = *account.ForcePasswordChange != 0 - } - return token, requirePasswordChange, nil -} - -func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Account, int64, error) { +func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) { peers, total, err := dao.PagePeer(peerPageDto) if err != nil { return nil, 0, err } - accounts := make([]entity.Account, 0, len(peers)) + onlineUsers, _ := Hysteria2Online() + result := make([]vo.PeerVo, 0, len(peers)) for _, p := range peers { - role := "user" - acc := entity.Account{ - BaseEntity: p.BaseEntity, - Username: p.Name, - ConPass: p.SecretCiphertext, - Quota: p.QuotaBytes, - Download: p.DownloadBytes, - Upload: p.UploadBytes, - ExpireTime: p.ExpiresAt, - KickUtilTime: p.BannedUntil, - DeviceNo: p.MaxDevices, - Role: &role, - Deleted: p.Disabled, - ConAt: p.LastConnectionAt, - Remark: p.Remark, + item := vo.PeerVo{ + BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime}, + Name: strVal(p.Name), + Remark: strVal(p.Remark), + AuthId: strVal(p.AuthId), + QuotaBytes: int64Val(p.QuotaBytes), + DownloadBytes: int64Val(p.DownloadBytes), + UploadBytes: int64Val(p.UploadBytes), + ExpiresAt: int64Val(p.ExpiresAt), + MaxDevices: int64Val(p.MaxDevices), + Disabled: int64Val(p.Disabled), + BannedUntil: int64Val(p.BannedUntil), + LastConnectionAt: int64Val(p.LastConnectionAt), } - accounts = append(accounts, acc) + if v, ok := onlineUsers[item.AuthId]; ok { + item.Online = true + item.OnlineDevices = v + } + result = append(result, item) } - return accounts, total, nil + return result, total, nil } -func SavePeer(account entity.Account) error { - if account.Username == nil || *account.Username == "" { - return errors.New(constant.InvalidError) +func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { + if peerDto.Name == nil || *peerDto.Name == "" { + return vo.PeerVo{}, errors.New(constant.InvalidError) + } + if ExistPeerName(*peerDto.Name, 0) { + return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name)) } secret := "" - if account.ConPass != nil && *account.ConPass != "" { - secret = *account.ConPass + if peerDto.Secret != nil && *peerDto.Secret != "" { + secret = *peerDto.Secret } else { - generated, genErr := util.RandomString(24) - if genErr != nil { - return genErr + generated, err := util.RandomString(24) + if err != nil { + return vo.PeerVo{}, err } - secret = fmt.Sprintf("%s.%s", *account.Username, generated) + secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated) } - authId, authErr := util.RandomString(18) - if authErr != nil { - return authErr + authId, err := util.RandomString(18) + if err != nil { + return vo.PeerVo{}, err + } + secretDigest, err := PeerSecretDigest(secret) + if err != nil { + return vo.PeerVo{}, err + } + secretEncrypted, err := EncryptPeerSecret(secret) + if err != nil { + return vo.PeerVo{}, err } - secretDigest := util.PeerSecretDigest(secret) peer := entity.Peer{ - Name: account.Username, - Remark: account.Remark, - AuthId: &authId, - SecretDigest: &secretDigest, - SecretCiphertext: &secret, - QuotaBytes: account.Quota, - ExpiresAt: account.ExpireTime, - MaxDevices: account.DeviceNo, - Disabled: account.Deleted, + Name: peerDto.Name, + Remark: peerDto.Remark, + AuthId: &authId, + SecretDigest: &secretDigest, + SecretEncrypted: &secretEncrypted, + QuotaBytes: peerDto.QuotaBytes, + ExpiresAt: peerDto.ExpiresAt, + MaxDevices: peerDto.MaxDevices, + Disabled: peerDto.Disabled, } - _, err := dao.SavePeer(peer) - return err + id, saveErr := dao.SavePeer(peer) + if saveErr != nil { + return vo.PeerVo{}, saveErr + } + return GetPeerVo(id) } -func DeletePeer(ids []int64) error { - return dao.DeletePeer(ids) -} - -func UpdatePeer(account entity.Account) error { +func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { updates := map[string]interface{}{} - if account.Username != nil && *account.Username != "" { - updates["username"] = *account.Username + if peerDto.Name != nil && *peerDto.Name != "" { + updates["name"] = *peerDto.Name } - _ = account.Pass - if account.ConPass != nil && *account.ConPass != "" { - updates["secret_ciphertext"] = *account.ConPass - updates["secret_digest"] = util.PeerSecretDigest(*account.ConPass) - } - if account.Quota != nil { - updates["quota_bytes"] = *account.Quota - } - if account.ExpireTime != nil { - updates["expires_at"] = *account.ExpireTime - } - if account.Download != nil { - updates["download_bytes"] = *account.Download - } - if account.Upload != nil { - updates["upload_bytes"] = *account.Upload - } - if account.DeviceNo != nil { - updates["max_devices"] = *account.DeviceNo - } - if account.Deleted != nil { - updates["disabled"] = *account.Deleted - } - if account.LoginAt != nil && *account.LoginAt > 0 { - updates["login_at"] = *account.LoginAt - } - if account.ConAt != nil && *account.ConAt > 0 { - updates["last_connection_at"] = *account.ConAt - } - if account.Remark != nil { - updates["remark"] = *account.Remark - } - return dao.UpdatePeer([]int64{*account.Id}, updates) -} - -func ResetTraffic(id int64) error { - return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}) -} - -func ExistPeerName(username string, id int64) bool { - var err error - if id != 0 { - _, err = dao.GetPeer("name = ? and id != ?", username, id) - } else { - _, err = dao.GetPeer("name = ?", username) - } - if err != nil { - if err.Error() == constant.WrongPassword { - return false + if peerDto.Secret != nil && *peerDto.Secret != "" { + digest, err := PeerSecretDigest(*peerDto.Secret) + if err != nil { + return err } + enc, err := EncryptPeerSecret(*peerDto.Secret) + if err != nil { + return err + } + updates["secret_digest"] = digest + updates["secret_ciphertext"] = enc } - return true + if peerDto.QuotaBytes != nil { + updates["quota_bytes"] = *peerDto.QuotaBytes + } + if peerDto.ExpiresAt != nil { + updates["expires_at"] = *peerDto.ExpiresAt + } + if peerDto.MaxDevices != nil { + updates["max_devices"] = *peerDto.MaxDevices + } + if peerDto.Disabled != nil { + updates["disabled"] = *peerDto.Disabled + } + if peerDto.Remark != nil { + updates["remark"] = *peerDto.Remark + } + return dao.UpdatePeer([]int64{id}, updates) } -func GetPeer(id int64) (entity.Account, error) { - peer, err := dao.GetPeer("id = ?", id) +func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } + +func GetPeerVo(id int64) (vo.PeerVo, error) { + p, err := dao.GetPeer("id = ?", id) if err != nil { - return entity.Account{}, err + return vo.PeerVo{}, err } - role := "user" - return entity.Account{ - BaseEntity: peer.BaseEntity, - Username: peer.Name, - ConPass: peer.SecretCiphertext, - Quota: peer.QuotaBytes, - Download: peer.DownloadBytes, - Upload: peer.UploadBytes, - ExpireTime: peer.ExpiresAt, - DeviceNo: peer.MaxDevices, - KickUtilTime: peer.BannedUntil, - ConAt: peer.LastConnectionAt, - Deleted: peer.Disabled, - Remark: peer.Remark, - Role: &role, + return vo.PeerVo{ + BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime}, + Name: strVal(p.Name), + Remark: strVal(p.Remark), + AuthId: strVal(p.AuthId), + QuotaBytes: int64Val(p.QuotaBytes), + DownloadBytes: int64Val(p.DownloadBytes), + UploadBytes: int64Val(p.UploadBytes), + ExpiresAt: int64Val(p.ExpiresAt), + MaxDevices: int64Val(p.MaxDevices), + Disabled: int64Val(p.Disabled), + BannedUntil: int64Val(p.BannedUntil), + LastConnectionAt: int64Val(p.LastConnectionAt), }, nil } -func GetAdminAccount(id int64) (entity.AdminUser, error) { - return dao.GetAdminUser("id = ?", id) +func ResetPeerTraffic(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}) } +func ReleaseKickPeer(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0}) } + +func KickPeer(id int64, bannedUntil int64) error { + if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil { + return err + } + return Hysteria2Kick([]int64{id}, bannedUntil) } -func ListExportPeer() ([]bo.AccountExport, error) { +func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) { + url, err := Hysteria2Url(id) + if err != nil { + return vo.PeerClientConfigVo{}, err + } + qrCode, err := qrcode.Encode(url, qrcode.Medium, 300) + if err != nil { + return vo.PeerClientConfigVo{}, err + } + return vo.PeerClientConfigVo{Url: url, QrCode: qrCode}, nil +} + +func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { peers, err := dao.ListPeer("1=1") if err != nil { return nil, errors.New(constant.SysError) } - var accountExports []bo.AccountExport + out := make([]bo.PeerExport, 0, len(peers)) for _, item := range peers { - role := "user" - conPass := "" - if item.SecretCiphertext != nil { - conPass = *item.SecretCiphertext + ex := bo.PeerExport{ + Id: int64Val(item.Id), + Name: strVal(item.Name), + Remark: strVal(item.Remark), + QuotaBytes: int64Val(item.QuotaBytes), + DownloadBytes: int64Val(item.DownloadBytes), + UploadBytes: int64Val(item.UploadBytes), + ExpiresAt: int64Val(item.ExpiresAt), + MaxDevices: int64Val(item.MaxDevices), + Disabled: int64Val(item.Disabled), + BannedUntil: int64Val(item.BannedUntil), + LastConnectionAt: int64Val(item.LastConnectionAt), } - accountExport := bo.AccountExport{ - Id: *item.Id, - Username: *item.Name, - Pass: "", - ConPass: conPass, - Quota: *item.QuotaBytes, - Download: *item.DownloadBytes, - Upload: *item.UploadBytes, - ExpireTime: *item.ExpiresAt, - DeviceNo: *item.MaxDevices, - KickUtilTime: *item.BannedUntil, - Role: role, - Deleted: *item.Disabled, - CreateTime: *item.CreateTime, - UpdateTime: *item.UpdateTime, - LoginAt: 0, - ConAt: *item.LastConnectionAt, - Remark: *item.Remark, + if includeSecrets && item.SecretEncrypted != nil { + if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil { + ex.Secret = dec + } } - accountExports = append(accountExports, accountExport) + out = append(out, ex) } - return accountExports, nil + return out, nil } -func ReleaseKickPeer(id int64) error { - return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0}) -} - -func UpsertPeer(accounts []entity.Account) error { +func UpsertPeerLegacy(accounts []entity.Account) error { for _, account := range accounts { if account.Id != nil && *account.Id > 0 { - if err := UpdatePeer(account); err != nil { + upd := dto.PeerUpdateDto{} + upd.Name = account.Username + upd.Remark = account.Remark + upd.QuotaBytes = account.Quota + upd.ExpiresAt = account.ExpireTime + upd.MaxDevices = account.DeviceNo + upd.Disabled = account.Deleted + if err := UpdatePeer(*account.Id, upd); err != nil { return err } continue } - if err := SavePeer(account); err != nil { + save := dto.PeerSaveDto{ + Name: account.Username, + Secret: account.ConPass, + QuotaBytes: account.Quota, + ExpiresAt: account.ExpireTime, + MaxDevices: account.DeviceNo, + Disabled: account.Deleted, + Remark: account.Remark, + } + if _, err := CreatePeer(save); err != nil { return err } } return nil } -func GetAdminInfo(c *gin.Context) (vo.AccountInfoVo, error) { - myClaims, err := ParseToken(GetToken(c)) - if err != nil { - return vo.AccountInfoVo{}, err +func ExistPeerName(name string, id int64) bool { + var err error + if id != 0 { + _, err = dao.GetPeer("name = ? and id != ?", name, id) + } else { + _, err = dao.GetPeer("name = ?", name) } - if myClaims.Admin.Deleted != 0 { - return vo.AccountInfoVo{}, errors.New("this account has been disabled") - } - admin, err := dao.GetAdminUser("id = ?", myClaims.Admin.Id) - if err != nil { - return vo.AccountInfoVo{}, err - } - if admin.Status != nil && *admin.Status == 0 { - return vo.AccountInfoVo{}, errors.New("this account has been disabled") - } - return vo.AccountInfoVo{ - Id: myClaims.Admin.Id, - Username: myClaims.Admin.Username, - Roles: myClaims.Admin.Roles, - }, nil + return err == nil } func UpdatePeerLastConnectionAt(id int64, conAt int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt}) } -func UpdateAdminLastLoginAt(id int64, loginAt int64) error { - return dao.UpdateAdminUser([]int64{id}, map[string]interface{}{"last_login_at": loginAt}) -} +func strVal(v *string) string { if v == nil { return "" }; return *v } +func int64Val(v *int64) int64 { if v == nil { return 0 }; return *v } -func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string) error { - info, err := GetAdminInfo(c) - if err != nil { - return err - } - admin, err := dao.GetAdminUser("id = ?", info.Id) - if err != nil { - return err - } - if admin.PasswordHash == nil { - return errors.New(constant.SysError) - } - verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash) - if !verified { - return errors.New(constant.WrongPassword) - } - hash, hashErr := util.HashPassword(newPassword) - if hashErr != nil { - return hashErr - } - nowMs := time.Now().UnixMilli() - currentTokenVersion := int64(1) - if admin.TokenVersion != nil && *admin.TokenVersion > 0 { - currentTokenVersion = *admin.TokenVersion - } - return dao.UpdateAdminUser([]int64{info.Id}, map[string]interface{}{ - "password_hash": hash, - "force_password_change": 0, - "password_changed_at": nowMs, - "token_version": currentTokenVersion + 1, - }) -} diff --git a/apps/service/peer_secret.go b/apps/service/peer_secret.go new file mode 100644 index 0000000..cec5a71 --- /dev/null +++ b/apps/service/peer_secret.go @@ -0,0 +1,85 @@ +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 +} + +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 +} + +func PeerSecretDigest(rawSecret string) (string, error) { + secretKey, err := GetPeerSecretKey() + if err != nil { + return "", err + } + return util.HmacSHA256Hex(rawSecret, secretKey), nil +} + +func EncryptPeerSecret(rawSecret string) (string, error) { + key, err := getPeerSecretEncryptionKey() + if err != nil { + return "", err + } + return util.EncryptAESGCM(rawSecret, key) +} + +func DecryptPeerSecret(stored string) (string, error) { + if !strings.HasPrefix(stored, "v1:") { + return stored, nil + } + key, err := getPeerSecretEncryptionKey() + if err != nil { + return "", err + } + return util.DecryptAESGCM(stored, key) +} + diff --git a/apps/util/encrypt.go b/apps/util/encrypt.go index 037199b..b0fecc6 100644 --- a/apps/util/encrypt.go +++ b/apps/util/encrypt.go @@ -1,11 +1,16 @@ package util import ( + "crypto/aes" + "crypto/cipher" "crypto/hmac" + "crypto/rand" "crypto/sha256" + "encoding/base64" "errors" "fmt" "os" + "io" "strings" "golang.org/x/crypto/bcrypt" @@ -56,6 +61,69 @@ func HmacSHA256Hex(payload string, secret string) string { return str } + +func DecodeBase64Key(raw string, expectedLen int) ([]byte, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("empty key") + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + if len(decoded) != expectedLen { + return nil, fmt.Errorf("invalid key length: expected %d, got %d", expectedLen, len(decoded)) + } + return decoded, nil +} + +func EncryptAESGCM(plainText string, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + cipherText := gcm.Seal(nil, nonce, []byte(plainText), nil) + payload := append(nonce, cipherText...) + return "v1:" + base64.StdEncoding.EncodeToString(payload), nil +} + +func DecryptAESGCM(cipherText string, key []byte) (string, error) { + if !strings.HasPrefix(cipherText, "v1:") { + return "", errors.New("unsupported ciphertext version") + } + encoded := strings.TrimPrefix(cipherText, "v1:") + payload, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(payload) < nonceSize { + return "", errors.New("invalid ciphertext") + } + nonce, enc := payload[:nonceSize], payload[nonceSize:] + plain, err := gcm.Open(nil, nonce, enc, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + func PeerSecretDigest(rawSecret string) string { secretKey := strings.TrimSpace(os.Getenv("HY2XS_PEER_SECRET_KEY")) if secretKey == "" {