Полный продакшен-рефактор fix25: split peer/admin, удаление legacy, шифрование secret, auth_id, новые API/роуты и зачистка subscription

This commit is contained in:
2026-05-09 00:27:45 +05:00
parent e60594e09e
commit d73bab99ec
34 changed files with 930 additions and 1673 deletions
+9
View File
@@ -28,3 +28,12 @@ func AdminChangePassword(c *gin.Context) {
vo.Success(nil, c) 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)
}
-58
View File
@@ -2,27 +2,12 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"strconv"
"strings"
"time" "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) { func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -48,19 +33,6 @@ func Hysteria2Auth(c *gin.Context) {
vo.Hysteria2AuthSuccess(username, c) 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) { func Hysteria2ChangeVersion(c *gin.Context) {
vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c) vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c)
} }
@@ -68,33 +40,3 @@ func Hysteria2ChangeVersion(c *gin.Context) {
func ListRelease(c *gin.Context) { func ListRelease(c *gin.Context) {
vo.Success([]string{}, c) 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)
}
+52 -234
View File
@@ -3,19 +3,19 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"hy2xs-admin/util" "hy2xs-admin/util"
"io"
"path/filepath"
"strconv"
"strings"
"time"
) )
func resolveID(c *gin.Context) (int64, error) { func resolveID(c *gin.Context) (int64, error) {
@@ -37,18 +37,12 @@ func Login(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass) token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
jwtVo := vo.JwtVo{ vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
TokenType: constant.TokenType,
AccessToken: token,
ForcePasswordChange: forcePasswordChange,
}
vo.Success(jwtVo, c)
} }
func PagePeer(c *gin.Context) { func PagePeer(c *gin.Context) {
@@ -56,85 +50,25 @@ func PagePeer(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
accounts, total, err := service.PagePeer(peerPageDto) records, total, err := service.PagePeer(peerPageDto)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
vo.Success(vo.PeerPageVo{Records: records, Total: total}, c)
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)
} }
func SavePeer(c *gin.Context) { func SavePeer(c *gin.Context) {
accountSaveDto, err := validateField(c, dto.AccountSaveDto{}) peerSaveDto, err := validateField(c, dto.PeerSaveDto{})
if err != nil { if err != nil {
return return
} }
peerVo, err := service.CreatePeer(peerSaveDto)
if service.ExistPeerName(*accountSaveDto.Username, 0) {
vo.Fail(fmt.Sprintf("username %s already exists", *accountSaveDto.Username), c)
return
}
passEncrypt, err := util.HashPassword(*accountSaveDto.Pass)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass) vo.Success(peerVo, c)
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)
} }
func DeletePeer(c *gin.Context) { func DeletePeer(c *gin.Context) {
@@ -142,17 +76,7 @@ func DeletePeer(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
account, err := service.GetPeer(id) if err = service.DeletePeer(id); err != nil {
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 {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
@@ -160,105 +84,19 @@ func DeletePeer(c *gin.Context) {
} }
func UpdatePeer(c *gin.Context) { func UpdatePeer(c *gin.Context) {
accountUpdateDto, err := validateField(c, dto.AccountUpdateDto{}) id, err := resolveID(c)
if err != nil { 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 return
} }
peerUpdateDto, err := validateField(c, dto.PeerUpdateDto{})
if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistPeerName(*accountUpdateDto.Username, *accountUpdateDto.Id) { if err != nil {
vo.Fail(fmt.Sprintf("username %s already exists", *accountUpdateDto.Username), c)
return return
} }
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 { vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
account, err := service.GetPeer(*accountUpdateDto.Id) return
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *account.Role == "admin" {
vo.Fail("the admin account cannot be deleted", c)
return
}
} }
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
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 {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
@@ -270,54 +108,24 @@ func ResetTraffic(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
if err = service.ResetTraffic(id); err != nil { if err = service.ResetPeerTraffic(id); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
vo.Success(nil, c) 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) { func GetPeer(c *gin.Context) {
id, err := resolveID(c) id, err := resolveID(c)
if err != nil { if err != nil {
return return
} }
account, err := service.GetPeer(id) peer, err := service.GetPeerVo(id)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
accountVo := vo.AccountVo{ vo.Success(peer, c)
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)
} }
func ImportPeer(c *gin.Context) { func ImportPeer(c *gin.Context) {
@@ -326,12 +134,10 @@ func ImportPeer(c *gin.Context) {
vo.Fail(constant.SysError, c) vo.Fail(constant.SysError, c)
return return
} }
// Размер файла 2 MB
if header.Size > 1024*1024*2 { if header.Size > 1024*1024*2 {
vo.Fail("the file is too big", c) vo.Fail("the file is too big", c)
return return
} }
// Расширение файла .json
if !strings.HasSuffix(header.Filename, ".json") { if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail(constant.InvalidError, c) vo.Fail(constant.InvalidError, c)
return return
@@ -346,7 +152,7 @@ func ImportPeer(c *gin.Context) {
vo.Fail("content Unmarshal err", c) vo.Fail("content Unmarshal err", c)
return return
} }
if err = service.UpsertPeer(accounts); err != nil { if err = service.UpsertPeerLegacy(accounts); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
@@ -354,21 +160,18 @@ func ImportPeer(c *gin.Context) {
} }
func ExportPeer(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 { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405"))
fileName := fmt.Sprintf("AccountExport-%s.json", time.Now().Format("20060102150405"))
filePath := filepath.Join(constant.ExportPathDir, fileName) filePath := filepath.Join(constant.ExportPathDir, fileName)
if err = util.ExportFile(filePath, peerExports, 0); err != nil {
if err = util.ExportFile(filePath, accountExports, 0); err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
// Скачивание
if !util.Exists(filePath) { if !util.Exists(filePath) {
vo.Fail("file not exist", c) vo.Fail("file not exist", c)
return return
@@ -385,23 +188,38 @@ func ReleaseKickPeer(c *gin.Context) {
return return
} }
if err = service.ReleaseKickPeer(id); err != nil { if err = service.ReleaseKickPeer(id); err != nil {
logrus.Debugf("release kick err: %v", err)
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
} }
func VerifyDefaultPass(c *gin.Context) { func KickPeer(c *gin.Context) {
info, err := service.GetAdminInfo(c) id, err := resolveID(c)
if err != nil { 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) vo.Fail(err.Error(), c)
return return
} }
admin, err := service.GetAdminAccount(info.Id) vo.Success(nil, c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(admin.PasswordHash != nil && !util.IsBcryptHash(*admin.PasswordHash), 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)
}
+4 -4
View File
@@ -64,11 +64,11 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
var peers []entity.Peer var peers []entity.Peer
var total int64 var total int64
tx := sqliteDB.Model(&entity.Peer{}) tx := sqliteDB.Model(&entity.Peer{})
if peerPageDto.Username != nil && *peerPageDto.Username != "" { if peerPageDto.Name != nil && *peerPageDto.Name != "" {
tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Username)) tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Name))
} }
if peerPageDto.Deleted != nil { if peerPageDto.Disabled != nil {
tx.Where("disabled = ?", *peerPageDto.Deleted) tx.Where("disabled = ?", *peerPageDto.Disabled)
} }
if peerPageDto.Remark != nil && *peerPageDto.Remark != "" { if peerPageDto.Remark != nil && *peerPageDto.Remark != "" {
tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Remark)) tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Remark))
+51 -2
View File
@@ -105,6 +105,8 @@ func ensureSecureBootstrapAdmin() error {
return pwdErr return pwdErr
} }
adminPassword = password adminPassword = password
logrus.Warnf("Initial admin username: %s", adminUser)
logrus.Warnf("Initial admin password: %s", adminPassword)
} }
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1) status := int64(1)
@@ -153,6 +155,7 @@ func runMigrations() error {
} }
migrations := []migration{ migrations := []migration{
{version: "000_base_config", apply: migrateBaseConfig},
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit}, {version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts}, {version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
{version: "003_archive_legacy_account", apply: archiveLegacyAccount}, {version: "003_archive_legacy_account", apply: archiveLegacyAccount},
@@ -178,6 +181,52 @@ func runMigrations() error {
return nil 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) { func migrationApplied(version string) (bool, error) {
var count int64 var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil { 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 return authErr
} }
secretDigest := util.PeerSecretDigest(*acc.ConPass) secretDigest := util.PeerSecretDigest(*acc.ConPass)
secretCiphertext := *acc.ConPass secretEncrypted := *acc.ConPass
quota := int64(0) quota := int64(0)
if acc.Quota != nil { if acc.Quota != nil {
quota = *acc.Quota quota = *acc.Quota
@@ -331,7 +380,7 @@ func migrateLegacyAccounts() error {
Remark: &remark, Remark: &remark,
AuthId: &authId, AuthId: &authId,
SecretDigest: &secretDigest, SecretDigest: &secretDigest,
SecretCiphertext: &secretCiphertext, SecretEncrypted: &secretEncrypted,
QuotaBytes: &quota, QuotaBytes: &quota,
DownloadBytes: &download, DownloadBytes: &download,
UploadBytes: &upload, UploadBytes: &upload,
+106 -68
View File
@@ -1,70 +1,108 @@
CREATE TABLE IF NOT EXISTS account -- Reference schema only. Runtime schema is managed by dao/sqlite.go migrations.
(
id INTEGER PRIMARY KEY AUTOINCREMENT, CREATE TABLE schema_migrations (
username TEXT NOT NULL UNIQUE DEFAULT '', version TEXT PRIMARY KEY,
pass TEXT NOT NULL DEFAULT '', applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
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
); );
ALTER TABLE account
ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0; CREATE TABLE config (
ALTER TABLE account id INTEGER PRIMARY KEY AUTOINCREMENT,
ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0; key TEXT NOT NULL UNIQUE DEFAULT '',
ALTER TABLE account value TEXT NOT NULL DEFAULT '',
ADD COLUMN remark INTEGER NOT NULL DEFAULT ''; remark TEXT NOT NULL DEFAULT '',
CREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted); create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CREATE INDEX IF NOT EXISTS account_username_index ON account (username); update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
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 INDEX IF NOT EXISTS config_key_index ON config (key); CREATE INDEX config_key_index ON config (key);
INSERT INTO config (key, value, remark)
SELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port' CREATE TABLE admin_user (
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT'); id INTEGER PRIMARY KEY AUTOINCREMENT,
INSERT INTO config (key, value, remark) username TEXT NOT NULL UNIQUE DEFAULT '',
SELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context' password_hash TEXT NOT NULL DEFAULT '',
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT'); status INTEGER NOT NULL DEFAULT 1,
INSERT INTO config (key, value, remark) force_password_change INTEGER NOT NULL DEFAULT 0,
SELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path' last_login_at INTEGER NOT NULL DEFAULT 0,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH'); password_changed_at INTEGER NOT NULL DEFAULT 0,
INSERT INTO config (key, value, remark) token_version INTEGER NOT NULL DEFAULT 1,
SELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path' create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH'); update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
INSERT INTO config (key, value, remark) );
SELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret' CREATE INDEX admin_user_username_index ON admin_user (username);
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET');
INSERT INTO config (key, value, remark) CREATE TABLE peer (
SELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch' id INTEGER PRIMARY KEY AUTOINCREMENT,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE'); name TEXT NOT NULL UNIQUE DEFAULT '',
INSERT INTO config (key, value, remark) remark TEXT NOT NULL DEFAULT '',
SELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config' auth_id TEXT NOT NULL UNIQUE DEFAULT '',
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG'); secret_digest TEXT NOT NULL UNIQUE DEFAULT '',
INSERT INTO config (key, value, remark) secret_ciphertext TEXT NOT NULL DEFAULT '',
SELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time' quota_bytes INTEGER NOT NULL DEFAULT 0,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME'); download_bytes INTEGER NOT NULL DEFAULT 0,
INSERT INTO config (key, value, remark) upload_bytes INTEGER NOT NULL DEFAULT 0,
SELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark' expires_at INTEGER NOT NULL DEFAULT 0,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK'); max_devices INTEGER NOT NULL DEFAULT 3,
INSERT INTO config (key, value, remark) disabled INTEGER NOT NULL DEFAULT 0,
SELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron' banned_until INTEGER NOT NULL DEFAULT 0,
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON'); last_connection_at INTEGER NOT NULL DEFAULT 0,
INSERT INTO config (key, value, remark) create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
SELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension' update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION'); );
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);
-8
View File
@@ -16,11 +16,3 @@ export function getAdminInfoApi(): AxiosPromise<AdminInfo> {
method: "get", method: "get",
}); });
} }
export function verifyDefaultPassApi(): AxiosPromise<boolean> {
return request({
url: "/admin/verify-default-pass",
method: "get",
});
}
-30
View File
@@ -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<Hysteria2ServerConfig> {
return request({
url: "/hysteria2/hysteria2Kick",
method: "post",
data: data,
});
}
export function hysteria2UrlApi(
dto: Hysteria2UrlDto
): AxiosPromise<Hysteria2UrlVo> {
return request({
url: "/hysteria2/hysteria2Url",
method: "get",
params: dto,
});
}
-14
View File
@@ -1,14 +0,0 @@
export interface Hysteria2KickDto {
ids: number[];
kickUtilTime: number;
}
export interface Hysteria2UrlDto {
accountId: number;
}
export interface Hysteria2UrlVo {
url: string;
qrCode: string;
}
+19
View File
@@ -1,6 +1,8 @@
import request from "@/utils/request"; import request from "@/utils/request";
import { AxiosPromise } from "axios"; import { AxiosPromise } from "axios";
import { import {
KickPeerDto,
PeerClientConfigVo,
PeerPageDto, PeerPageDto,
PeerSaveDto, PeerSaveDto,
PeerUpdateDto, 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<PeerClientConfigVo> {
return request({
url: `/peers/${id}/client-config`,
method: "get",
});
}
export function importPeerApi(data: FormData): AxiosPromise { export function importPeerApi(data: FormData): AxiosPromise {
return request({ return request({
url: "/peers/import", url: "/peers/import",
+32 -43
View File
@@ -1,62 +1,51 @@
export interface PeerPageDto extends BaseDto { export interface PeerPageDto extends BaseDto {
username?: string; name?: string;
deleted?: number; disabled?: number;
remark?: string; remark?: string;
} }
export interface PeerUpdateDto extends IdDto { export interface PeerUpdateDto extends IdDto {
username: string; name?: string;
pass: string; secret?: string;
conPass: string; quotaBytes?: number;
quota: number; expiresAt?: number;
expireTime: number; maxDevices?: number;
deviceNo: number; disabled?: number;
deleted: number; remark?: string;
remark: string;
} }
export interface PeerSaveDto { export interface PeerSaveDto {
username: string; name: string;
pass: string; secret?: string;
conPass: string; quotaBytes: number;
quota: number; expiresAt: number;
expireTime: number; maxDevices: number;
deviceNo: number; disabled: number;
deleted: number;
remark: string; remark: string;
} }
export interface PeerVo extends IdDto { export interface PeerVo extends IdDto {
username: string; name: string;
quota: number; remark: string;
download: number; authId: string;
upload: number; quotaBytes: number;
expireTime: number; downloadBytes: number;
kickUtilTime: number; uploadBytes: number;
deviceNo: number; expiresAt: number;
role: string; maxDevices: number;
deleted: number; disabled: number;
bannedUntil: number;
lastConnectionAt: number;
createTime: string; createTime: string;
online: boolean; online: boolean;
device: number; onlineDevices: number;
loginAt: number;
conAt: number;
remark: string;
} }
export interface PeerForm extends IdDto { export interface PeerClientConfigVo {
username: string; url: string;
pass: string; qrCode: string | Uint8Array;
conPass: string;
quota: number;
expireTime: number;
deviceNo: number;
deleted: number;
remark: string;
} }
export interface KickPeerForm { export interface KickPeerDto {
ids: number[]; bannedUntil: number;
kickUtilTime: number;
} }
-3
View File
@@ -26,9 +26,6 @@ declare module '@vue/runtime-core' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination'] 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'] ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
+1 -2
View File
@@ -8,8 +8,7 @@ declare module "*.vue" {
} }
// TypeScript-подсказки для переменных окружения // TypeScript-подсказки для переменных окружения
interface ImportMetaEnv { type ImportMetaEnv = Record<string, string | boolean | undefined>;
}
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -3,10 +3,11 @@ package bo
import "time" import "time"
type AccountBo struct { type AccountBo struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Roles []string `json:"roles"` Roles []string `json:"roles"`
Deleted int64 `json:"deleted"` Deleted int64 `json:"deleted"`
TokenVersion int64 `json:"tokenVersion"`
} }
type AccountExport struct { type AccountExport struct {
@@ -28,3 +29,18 @@ type AccountExport struct {
ConAt int64 `json:"conAt"` ConAt int64 `json:"conAt"`
Remark string `json:"remark"` 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"`
}
-27
View File
@@ -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"`
}
-8
View File
@@ -1,8 +0,0 @@
package constant
const (
Shadowrocket = "shadowrocket"
Clash = "clash"
V2rayN = "v2rayn"
NekoBox = "nekobox"
)
+2
View File
@@ -6,6 +6,8 @@ const (
HUICrtPath = "H_UI_CRT_PATH" HUICrtPath = "H_UI_CRT_PATH"
HUIKeyPath = "H_UI_KEY_PATH" HUIKeyPath = "H_UI_KEY_PATH"
JwtSecret = "JWT_SECRET" JwtSecret = "JWT_SECRET"
PeerSecretKey = "PEER_SECRET_KEY"
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET" Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
Hysteria2Enable = "HYSTERIA2_ENABLE" Hysteria2Enable = "HYSTERIA2_ENABLE"
Hysteria2Config = "HYSTERIA2_CONFIG" Hysteria2Config = "HYSTERIA2_CONFIG"
-36
View File
@@ -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"`
}
+7
View File
@@ -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"`
}
-9
View File
@@ -6,15 +6,6 @@ type Hysteria2AuthDto struct {
Tx *int64 `json:"tx" form:"tx" validate:"required"` 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 { type Hysteria2VersionDto struct {
Version *string `json:"version" form:"version" validate:"required,min=1,max=10"` 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"`
}
+34
View File
@@ -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"`
}
+1 -1
View File
@@ -5,7 +5,7 @@ type Peer struct {
Remark *string `gorm:"column:remark;default:''" json:"remark"` Remark *string `gorm:"column:remark;default:''" json:"remark"`
AuthId *string `gorm:"column:auth_id;default:''" json:"authId"` AuthId *string `gorm:"column:auth_id;default:''" json:"authId"`
SecretDigest *string `gorm:"column:secret_digest;default:''" json:"secretDigest"` 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"` QuotaBytes *int64 `gorm:"column:quota_bytes;default:0" json:"quotaBytes"`
DownloadBytes *int64 `gorm:"column:download_bytes;default:0" json:"downloadBytes"` DownloadBytes *int64 `gorm:"column:download_bytes;default:0" json:"downloadBytes"`
UploadBytes *int64 `gorm:"column:upload_bytes;default:0" json:"uploadBytes"` UploadBytes *int64 `gorm:"column:upload_bytes;default:0" json:"uploadBytes"`
-31
View File
@@ -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"`
}
+9
View File
@@ -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"`
}
+30
View File
@@ -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"`
}
+1 -1
View File
@@ -10,7 +10,7 @@ func initAdminRouter(adminApi *gin.RouterGroup) {
{ {
admin.GET("/me", controller.AdminMe) admin.GET("/me", controller.AdminMe)
admin.POST("/change-password", controller.AdminChangePassword) admin.POST("/change-password", controller.AdminChangePassword)
admin.GET("/verify-default-pass", controller.VerifyDefaultPass) admin.GET("/security", controller.AdminSecurity)
} }
} }
-2
View File
@@ -15,9 +15,7 @@ func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) {
func initHysteria2Router(hysteria2Api *gin.RouterGroup) { func initHysteria2Router(hysteria2Api *gin.RouterGroup) {
hysteria2 := hysteria2Api.Group("/hysteria2") hysteria2 := hysteria2Api.Group("/hysteria2")
{ {
hysteria2.POST("/hysteria2Kick", controller.Hysteria2Kick)
hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion) hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion)
hysteria2.GET("/listRelease", controller.ListRelease) hysteria2.GET("/listRelease", controller.ListRelease)
hysteria2.GET("/hysteria2Url", controller.Hysteria2Url)
} }
} }
+5 -4
View File
@@ -16,12 +16,13 @@ func initPeerRouter(peerApi *gin.RouterGroup) {
peers.POST("/:id/reset-traffic", controller.ResetTraffic) peers.POST("/:id/reset-traffic", controller.ResetTraffic)
peers.POST("/:id/release-kick", controller.ReleaseKickPeer) 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("/import", controller.ImportPeer)
peers.POST("/export", controller.ExportPeer) peers.POST("/export", controller.ExportPeer)
// совместимость с текущим transport-форматом (query accountId) // aliases на один миграционный релиз
peers.GET("/:id/client-url", controller.Hysteria2Url) peers.GET("/:id/client-url", controller.PeerClientConfig)
peers.GET("/:id/qr", controller.Hysteria2Url) peers.GET("/:id/qr", controller.PeerClientConfig)
} }
} }
+98
View File
@@ -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,
})
}
+9 -1
View File
@@ -146,7 +146,15 @@ func Hysteria2Url(accountId int64) (string, error) {
sni = hysteria2Config.ACME.Domains[0] 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 { func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
+176 -245
View File
@@ -3,7 +3,8 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
@@ -11,311 +12,241 @@ import (
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/util" "hy2xs-admin/util"
"time"
) )
func Login(username string, plainPassword string) (string, bool, error) { func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, 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) {
peers, total, err := dao.PagePeer(peerPageDto) peers, total, err := dao.PagePeer(peerPageDto)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
accounts := make([]entity.Account, 0, len(peers)) onlineUsers, _ := Hysteria2Online()
result := make([]vo.PeerVo, 0, len(peers))
for _, p := range peers { for _, p := range peers {
role := "user" item := vo.PeerVo{
acc := entity.Account{ BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
BaseEntity: p.BaseEntity, Name: strVal(p.Name),
Username: p.Name, Remark: strVal(p.Remark),
ConPass: p.SecretCiphertext, AuthId: strVal(p.AuthId),
Quota: p.QuotaBytes, QuotaBytes: int64Val(p.QuotaBytes),
Download: p.DownloadBytes, DownloadBytes: int64Val(p.DownloadBytes),
Upload: p.UploadBytes, UploadBytes: int64Val(p.UploadBytes),
ExpireTime: p.ExpiresAt, ExpiresAt: int64Val(p.ExpiresAt),
KickUtilTime: p.BannedUntil, MaxDevices: int64Val(p.MaxDevices),
DeviceNo: p.MaxDevices, Disabled: int64Val(p.Disabled),
Role: &role, BannedUntil: int64Val(p.BannedUntil),
Deleted: p.Disabled, LastConnectionAt: int64Val(p.LastConnectionAt),
ConAt: p.LastConnectionAt,
Remark: p.Remark,
} }
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 { func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if account.Username == nil || *account.Username == "" { if peerDto.Name == nil || *peerDto.Name == "" {
return errors.New(constant.InvalidError) 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 := "" secret := ""
if account.ConPass != nil && *account.ConPass != "" { if peerDto.Secret != nil && *peerDto.Secret != "" {
secret = *account.ConPass secret = *peerDto.Secret
} else { } else {
generated, genErr := util.RandomString(24) generated, err := util.RandomString(24)
if genErr != nil { if err != nil {
return genErr 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) authId, err := util.RandomString(18)
if authErr != nil { if err != nil {
return authErr 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{ peer := entity.Peer{
Name: account.Username, Name: peerDto.Name,
Remark: account.Remark, Remark: peerDto.Remark,
AuthId: &authId, AuthId: &authId,
SecretDigest: &secretDigest, SecretDigest: &secretDigest,
SecretCiphertext: &secret, SecretEncrypted: &secretEncrypted,
QuotaBytes: account.Quota, QuotaBytes: peerDto.QuotaBytes,
ExpiresAt: account.ExpireTime, ExpiresAt: peerDto.ExpiresAt,
MaxDevices: account.DeviceNo, MaxDevices: peerDto.MaxDevices,
Disabled: account.Deleted, Disabled: peerDto.Disabled,
} }
_, err := dao.SavePeer(peer) id, saveErr := dao.SavePeer(peer)
return err if saveErr != nil {
return vo.PeerVo{}, saveErr
}
return GetPeerVo(id)
} }
func DeletePeer(ids []int64) error { func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
return dao.DeletePeer(ids)
}
func UpdatePeer(account entity.Account) error {
updates := map[string]interface{}{} updates := map[string]interface{}{}
if account.Username != nil && *account.Username != "" { if peerDto.Name != nil && *peerDto.Name != "" {
updates["username"] = *account.Username updates["name"] = *peerDto.Name
} }
_ = account.Pass if peerDto.Secret != nil && *peerDto.Secret != "" {
if account.ConPass != nil && *account.ConPass != "" { digest, err := PeerSecretDigest(*peerDto.Secret)
updates["secret_ciphertext"] = *account.ConPass if err != nil {
updates["secret_digest"] = util.PeerSecretDigest(*account.ConPass) return err
}
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
} }
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) { func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
peer, err := dao.GetPeer("id = ?", id)
func GetPeerVo(id int64) (vo.PeerVo, error) {
p, err := dao.GetPeer("id = ?", id)
if err != nil { if err != nil {
return entity.Account{}, err return vo.PeerVo{}, err
} }
role := "user" return vo.PeerVo{
return entity.Account{ BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
BaseEntity: peer.BaseEntity, Name: strVal(p.Name),
Username: peer.Name, Remark: strVal(p.Remark),
ConPass: peer.SecretCiphertext, AuthId: strVal(p.AuthId),
Quota: peer.QuotaBytes, QuotaBytes: int64Val(p.QuotaBytes),
Download: peer.DownloadBytes, DownloadBytes: int64Val(p.DownloadBytes),
Upload: peer.UploadBytes, UploadBytes: int64Val(p.UploadBytes),
ExpireTime: peer.ExpiresAt, ExpiresAt: int64Val(p.ExpiresAt),
DeviceNo: peer.MaxDevices, MaxDevices: int64Val(p.MaxDevices),
KickUtilTime: peer.BannedUntil, Disabled: int64Val(p.Disabled),
ConAt: peer.LastConnectionAt, BannedUntil: int64Val(p.BannedUntil),
Deleted: peer.Disabled, LastConnectionAt: int64Val(p.LastConnectionAt),
Remark: peer.Remark,
Role: &role,
}, nil }, nil
} }
func GetAdminAccount(id int64) (entity.AdminUser, error) { func ResetPeerTraffic(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}) }
return dao.GetAdminUser("id = ?", id) 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") peers, err := dao.ListPeer("1=1")
if err != nil { if err != nil {
return nil, errors.New(constant.SysError) return nil, errors.New(constant.SysError)
} }
var accountExports []bo.AccountExport out := make([]bo.PeerExport, 0, len(peers))
for _, item := range peers { for _, item := range peers {
role := "user" ex := bo.PeerExport{
conPass := "" Id: int64Val(item.Id),
if item.SecretCiphertext != nil { Name: strVal(item.Name),
conPass = *item.SecretCiphertext 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{ if includeSecrets && item.SecretEncrypted != nil {
Id: *item.Id, if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
Username: *item.Name, ex.Secret = dec
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,
} }
accountExports = append(accountExports, accountExport) out = append(out, ex)
} }
return accountExports, nil return out, nil
} }
func ReleaseKickPeer(id int64) error { func UpsertPeerLegacy(accounts []entity.Account) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
}
func UpsertPeer(accounts []entity.Account) error {
for _, account := range accounts { for _, account := range accounts {
if account.Id != nil && *account.Id > 0 { 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 return err
} }
continue 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 err
} }
} }
return nil return nil
} }
func GetAdminInfo(c *gin.Context) (vo.AccountInfoVo, error) { func ExistPeerName(name string, id int64) bool {
myClaims, err := ParseToken(GetToken(c)) var err error
if err != nil { if id != 0 {
return vo.AccountInfoVo{}, err _, err = dao.GetPeer("name = ? and id != ?", name, id)
} else {
_, err = dao.GetPeer("name = ?", name)
} }
if myClaims.Admin.Deleted != 0 { return err == nil
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
} }
func UpdatePeerLastConnectionAt(id int64, conAt int64) error { func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt}) return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt})
} }
func UpdateAdminLastLoginAt(id int64, loginAt int64) error { func strVal(v *string) string { if v == nil { return "" }; return *v }
return dao.UpdateAdminUser([]int64{id}, map[string]interface{}{"last_login_at": loginAt}) 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,
})
}
+85
View File
@@ -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)
}
+68
View File
@@ -1,11 +1,16 @@
package util package util
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac" "crypto/hmac"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"io"
"strings" "strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -56,6 +61,69 @@ func HmacSHA256Hex(payload string, secret string) string {
return str 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 { func PeerSecretDigest(rawSecret string) string {
secretKey := strings.TrimSpace(os.Getenv("HY2XS_PEER_SECRET_KEY")) secretKey := strings.TrimSpace(os.Getenv("HY2XS_PEER_SECRET_KEY"))
if secretKey == "" { if secretKey == "" {