Полный продакшен-рефактор 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)
}
+50 -232
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 return
} }
var body map[string]interface{} peerUpdateDto, err := validateField(c, dto.PeerUpdateDto{})
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)
return
}
if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 {
account, err := service.GetPeer(*accountUpdateDto.Id)
if err != nil { if err != nil {
vo.Fail(err.Error(), c)
return return
} }
if *account.Role == "admin" { if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
vo.Fail("the admin account cannot be deleted", c) vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
return 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 {
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
}
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 { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
admin, err := service.GetAdminAccount(info.Id) vo.Success(data, c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(admin.PasswordHash != nil && !util.IsBcryptHash(*admin.PasswordHash), 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,
+100 -62
View File
@@ -1,32 +1,11 @@
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
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, id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE DEFAULT '', key TEXT NOT NULL UNIQUE DEFAULT '',
value TEXT NOT NULL DEFAULT '', value TEXT NOT NULL DEFAULT '',
@@ -34,37 +13,96 @@ CREATE TABLE IF NOT EXISTS config
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_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;
+105 -832
View File
@@ -1,271 +1,46 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<div class="search"> <el-form :model="queryParams" :inline="true" class="mb-3">
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> <el-form-item :label="$t('peer.username')">
<el-form-item :label="$t('peer.remark')" prop="remark"> <el-input v-model="queryParams.name" clearable style="width: 220px" />
<el-input
v-model="queryParams.remark"
:placeholder="$t('peer.remark')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.username')" prop="username"> <el-form-item :label="$t('peer.remark')">
<el-input <el-input v-model="queryParams.remark" clearable style="width: 220px" />
v-model="queryParams.username"
:placeholder="$t('peer.username')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('common.deleted')" prop="deleted">
<el-select
v-model="queryParams.deleted"
:placeholder="$t('common.all')"
clearable
style="width: 200px"
>
<el-option :label="$t('common.enable')" value="0" />
<el-option :label="$t('common.disable')" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery"
>{{ $t("common.search") }}
</el-button>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button :icon="Refresh" @click="resetQuery" <el-button type="primary" @click="handleQuery">{{ $t("common.search") }}</el-button>
>{{ $t("common.reset") }}
</el-button>
</el-form-item>
<el-form-item>
<el-button :icon="Plus" @click="handleAdd"
>{{ $t("common.add") }}
</el-button>
</el-form-item>
<el-form-item>
<el-upload
v-model:file-list="fileList"
:http-request="handleImport"
:show-file-list="false"
accept=".json"
:limit="1"
:before-upload="beforeImport"
>
<el-button>
<template #icon>
<i-ep-upload />
</template>
{{ $t("common.import") }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div>
<el-card shadow="never"> <el-card shadow="never">
<div class="mb-3">
<el-button type="primary" @click="handleAdd">{{ $t("common.add") }}</el-button>
</div>
<el-table v-loading="loading" :data="records"> <el-table v-loading="loading" :data="records">
<el-table-column <el-table-column prop="id" :label="$t('common.id')" width="80" />
key="id" <el-table-column prop="name" :label="$t('peer.username')" min-width="180" />
:label="$t('common.id')" <el-table-column prop="remark" :label="$t('peer.remark')" min-width="200" />
align="center" <el-table-column :label="$t('peer.quota')" min-width="140">
prop="id" <template #default="scope">{{ formatBytes(scope.row.quotaBytes) }}</template>
/>
<el-table-column
key="remark"
:label="$t('peer.remark')"
align="center"
prop="remark"
/>
<el-table-column
key="username"
:label="$t('peer.username')"
align="center"
prop="username"
/>
<el-table-column
key="quota"
:label="$t('peer.quota')"
align="center"
prop="quota"
>
<template #default="scope">
{{ formatBytes(scope.row.quota) }}
</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column :label="$t('peer.download')" min-width="140">
key="download" <template #default="scope">{{ formatBytes(scope.row.downloadBytes) }}</template>
:label="$t('peer.download')"
align="center"
prop="download"
>
<template #default="scope">
{{ formatBytes(scope.row.download) }}
</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column :label="$t('peer.upload')" min-width="140">
key="upload" <template #default="scope">{{ formatBytes(scope.row.uploadBytes) }}</template>
:label="$t('peer.upload')"
align="center"
prop="upload"
>
<template #default="scope">
{{ formatBytes(scope.row.upload) }}
</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column :label="$t('peer.expireTime')" min-width="170">
key="online" <template #default="scope">{{ timestampToDateTime(scope.row.expiresAt) }}</template>
:label="$t('peer.onlineStatus')"
align="center"
prop="online"
>
<template #default="scope">
<el-tag v-if="scope.row.online" type="success"
>{{ $t("peer.online") }}
</el-tag>
<el-tag v-else type="info">{{ $t("peer.offline") }}</el-tag>
</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column :label="$t('common.operate')" width="320">
key="device"
:label="$t('peer.device')"
align="center"
prop="device"
/>
<el-table-column
key="deviceNo"
:label="$t('peer.deviceNo')"
align="center"
prop="deviceNo"
/>
<el-table-column
key="kickUtilTime"
:label="$t('peer.kickUtilTimeLast')"
align="center"
prop="kickUtilTime"
>
<template #default="scope"> <template #default="scope">
{{ calculateTimeDifference(scope.row.kickUtilTime) }} <el-button link type="primary" @click="copyUri(scope.row)">URI</el-button>
</template> <el-button link type="primary" @click="showQr(scope.row)">QR</el-button>
</el-table-column> <el-button link type="primary" @click="handleUpdate(scope.row)">{{ $t("common.edit") }}</el-button>
<el-table-column <el-button link type="danger" @click="handleDelete(scope.row)">{{ $t("common.delete") }}</el-button>
key="expireTime"
:label="$t('peer.expireTime')"
align="center"
prop="expireTime"
width="160"
>
<template #default="scope">
{{ timestampToDateTime(scope.row.expireTime) }}
</template>
</el-table-column>
<el-table-column
key="loginAt"
:label="$t('peer.loginAt')"
align="center"
prop="loginAt"
width="160"
>
<template #default="scope">
{{
scope.row.loginAt ? timestampToDateTime(scope.row.loginAt) : "-"
}}
</template>
</el-table-column>
<el-table-column
key="conAt"
:label="$t('peer.conAt')"
align="center"
prop="conAt"
width="160"
>
<template #default="scope">
{{ scope.row.conAt ? timestampToDateTime(scope.row.conAt) : "-" }}
</template>
</el-table-column>
<el-table-column
:label="$t('common.createTime')"
align="center"
prop="createTime"
width="160"
>
<template #default="scope">
{{ timestampToDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column
key="deleted"
:label="$t('common.deleted')"
align="center"
prop="deleted"
>
<template #default="scope">
<el-tag v-if="scope.row.deleted === 0" type="success"
>{{ $t("common.enable") }}
</el-tag>
<el-tag v-else type="danger">{{ $t("common.disable") }}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('common.operate')"
align="center"
width="300"
>
<template #default="scope">
<el-button type="primary" link @click="handleNodeUrl(scope.row)"
>{{ $t("common.nodeUrl") }}
</el-button>
<el-button type="primary" link @click="handleQrCode(scope.row)">
{{ $t("common.nodeQrCode") }}
</el-button>
<el-popconfirm
:title="$t('common.resetTrafficConfirm')"
@confirm="resetTraffic(scope.row)"
>
<template #reference>
<el-button type="primary" link
>{{ $t("common.resetTraffic") }}
</el-button>
</template>
</el-popconfirm>
<el-button type="primary" link @click="handleUpdate(scope.row)"
>{{ $t("common.edit") }}
</el-button>
<el-button type="danger" link @click="handleDelete(scope.row)"
>{{ $t("common.delete") }}
</el-button>
<el-button type="danger" link @click="handleKick(scope.row)"
>{{ $t("peer.kick") }}
</el-button>
<el-popconfirm
:title="$t('peer.releaseKickTip')"
@confirm="confirmReleaseKick(scope.row)"
v-if="calculateTimeDifference(scope.row.kickUtilTime) !== '-'"
>
<template #reference>
<el-button type="danger" link
>{{ $t("peer.releaseKick") }}
</el-button>
</template>
</el-popconfirm>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<pagination <pagination
v-if="total > 0" v-if="total > 0"
:total="total" :total="total"
@@ -275,622 +50,120 @@
/> />
</el-card> </el-card>
<el-dialog <el-dialog v-model="dialog.visible" :title="dialog.title" width="620px">
:title="dialog.title" <el-form :model="dataForm" label-width="140px">
v-model="dialog.visible" <el-form-item label="Name"><el-input v-model="dataForm.name" /></el-form-item>
width="620px" <el-form-item label="Remark"><el-input v-model="dataForm.remark" /></el-form-item>
append-to-body <el-form-item label="Secret"><el-input v-model="dataForm.secret" show-password /></el-form-item>
@close="closeDialog" <el-form-item label="Quota"><el-input-number v-model="dataForm.quotaBytes" :min="-1" /></el-form-item>
> <el-form-item label="Expires"><el-date-picker v-model="dataForm.expiresAt" type="datetime" value-format="x" /></el-form-item>
<el-form <el-form-item label="Max devices"><el-input-number v-model="dataForm.maxDevices" :min="1" /></el-form-item>
ref="dataFormRef" <el-form-item label="Disabled"><el-switch v-model="disabledBool" /></el-form-item>
:rules="
dialog.title === t('common.add')
? dataFormAddRules
: dataFormUpdateRules
"
:model="dataForm"
label-width="100px"
>
<el-form-item :label="$t('peer.remark')" prop="remark">
<el-input
v-model="dataForm.remark"
:placeholder="$t('peer.remark')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('peer.username')" prop="username">
<el-input
v-model="dataForm.username"
:placeholder="$t('peer.username')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('peer.conPass')" prop="conPass">
<el-input
v-model="dataForm.conPass"
:placeholder="$t('peer.conPass')"
maxlength="50"
clearable
type="password"
show-password
/>
</el-form-item>
<el-form-item :label="$t('peer.quota')" prop="quota">
<unit-select :setValue="setQuota" :valueTmp="quotaTmp" />
</el-form-item>
<el-form-item :label="$t('peer.deviceNo')" prop="deviceNo">
<el-input-number
v-model="dataForm.deviceNo"
:placeholder="$t('peer.deviceNo')"
:min="1"
:controls="false"
:precision="0"
clearable
style="width: 220px"
/>
</el-form-item>
<el-form-item :label="$t('peer.expireTime')" prop="expireTime">
<el-date-picker
v-model="dataForm.expireTime"
type="datetime"
:placeholder="$t('peer.expireTime')"
value-format="x"
:shortcuts="shortcuts"
clearable
/>
</el-form-item>
<el-form-item :label="$t('common.deleted')" prop="deleted">
<el-radio-group v-model="dataForm.deleted">
<el-radio :label="0">{{ $t("common.enable") }}</el-radio>
<el-radio :label="1">{{ $t("common.disable") }}</el-radio>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <el-button type="primary" @click="submitForm">{{ $t("common.confirm") }}</el-button>
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog <el-dialog v-model="qrDialog" title="QR" width="420px">
:title="dialogKick.title" <el-image style="width:300px;height:300px" :src="qrSrc" />
v-model="dialogKick.visible"
width="600px"
append-to-body
@close="closeDialogKick"
>
<el-form ref="kickFormRef" :model="kickForm" label-width="100px">
<el-form-item :label="$t('peer.kickUtilTime')" prop="kickUtilTime">
<el-date-picker
v-model="kickForm.kickUtilTime"
type="datetime"
:placeholder="$t('peer.kickUtilTime')"
value-format="x"
:shortcuts="shortcutsKick"
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitKickForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialogKick"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="qrCodeDialog.title"
v-model="qrCodeDialog.visible"
width="600px"
append-to-body
@close="qrCodeDialog.visible = false"
>
<el-form style="text-align: center">
<el-image
style="width: 300px; height: 300px"
:src="qrCodeSrc"
></el-image>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="qrCodeDialog.visible = false"
>{{ $t("common.confirm") }}
</el-button>
</div>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts"> <script setup lang="ts">
import { import { computed, onMounted, reactive, ref } from "vue";
KickPeerForm, import { useI18n } from "vue-i18n";
PeerForm, import copy from "copy-to-clipboard";
PeerPageDto, import { formatBytes } from "@/utils/byte";
PeerUpdateDto, import { getMonthLater, timestampToDateTime } from "@/utils/time";
PeerVo,
} from "@/api/peer/types";
import { import {
deletePeerApi, deletePeerApi,
exportPeerApi,
getPeerApi, getPeerApi,
importPeerApi, getPeerClientConfigApi,
pagePeerApi, pagePeerApi,
releaseKickPeerApi,
resetPeerTrafficApi,
savePeerApi, savePeerApi,
updatePeerApi, updatePeerApi,
} from "@/api/peer"; } from "@/api/peer";
import { Search, Plus, Refresh } from "@element-plus/icons-vue"; import { PeerPageDto, PeerSaveDto, PeerUpdateDto, PeerVo } from "@/api/peer/types";
import {
timestampToDateTime,
getMonthLater,
getWeekLater,
getYearLater,
calculateTimeDifference,
getHourLater,
getDayLater,
} from "@/utils/time";
import { formatBytes } from "@/utils/byte";
import {
hysteria2KickApi,
hysteria2UrlApi,
} from "@/api/hysteria2";
import {
UploadFile,
UploadRawFile,
UploadRequestOptions,
} from "element-plus/lib/components";
import { useI18n } from "vue-i18n";
import { Hysteria2UrlDto } from "@/api/hysteria2/types";
import copy from "copy-to-clipboard";
const { t } = useI18n(); const { t } = useI18n();
const queryFormRef = ref(ElForm); // Форма поиска const loading = ref(false);
const dataFormRef = ref(ElForm); // Форма пользователя const total = ref(0);
const kickFormRef = ref(ElForm); // Форма отключения пользователя const records = ref<PeerVo[]>([]);
const qrDialog = ref(false);
const qrSrc = ref("");
const dataFormAddRules = { const queryParams = reactive<PeerPageDto>({ pageNum: 1, pageSize: 10, name: undefined, remark: undefined, disabled: undefined });
remark: [ const dialog = reactive({ visible: false, title: "", editId: 0 });
{ const dataForm = reactive<PeerSaveDto & { id?: number }>({
min: 0, name: "",
max: 32, secret: "",
message: "Remark format is incorrect", quotaBytes: 0,
trigger: ["change", "blur"], expiresAt: getMonthLater(),
}, maxDevices: 3,
], disabled: 0,
username: [ remark: "",
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Username format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "ConPass format is incorrect",
trigger: ["change", "blur"],
},
],
expireTime: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
],
deviceNo: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
],
deleted: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
],
};
const dataFormUpdateRules = {
remark: [
{
min: 0,
max: 32,
message: "Remark format is incorrect",
trigger: ["change", "blur"],
},
],
username: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Username format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
};
const shortcuts = [
{
text: t("common.weekLater"),
value: getWeekLater,
},
{
text: t("common.monthLater"),
value: getMonthLater,
},
{
text: t("common.yearLater"),
value: getYearLater,
},
];
const shortcutsKick = [
{
text: t("common.hourLater"),
value: getHourLater,
},
{
text: t("common.dayLater"),
value: getDayLater,
},
{
text: t("common.weekLater"),
value: getWeekLater,
},
{
text: t("common.monthLater"),
value: getMonthLater,
},
];
const state = reactive({
loading: true,
total: 0,
records: [] as PeerVo[],
dialog: {
visible: false,
} as DialogType,
dialogKick: {
visible: false,
} as DialogType,
dataForm: {
quota: 0,
expireTime: getMonthLater(),
deviceNo: 6,
deleted: 0,
} as PeerForm,
kickForm: {
kickUtilTime: getHourLater(),
} as KickPeerForm,
queryParams: {
remark: undefined,
username: undefined,
deleted: undefined,
pageNum: 1,
pageSize: 10,
} as PeerPageDto,
quotaTmp: 0,
fileList: [] as UploadFile[],
qrCodeDialog: {
title: "QR Code",
visible: false,
} as DialogType,
qrCodeSrc: "",
}); });
const { const disabledBool = computed({
loading, get: () => dataForm.disabled === 1,
total, set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
records,
dialog,
dialogKick,
dataForm,
kickForm,
queryParams,
quotaTmp,
fileList,
qrCodeDialog,
qrCodeSrc,
} = toRefs(state);
const resetDataForm = () => {
Object.assign(state.dataForm, {
id: undefined,
quota: 0,
expireTime: getMonthLater(),
deleted: 0,
}); });
quotaTmp.value = 0;
};
/** async function handleQuery() {
* Поиск loading.value = true;
*/
const handleQuery = async () => {
state.loading = true;
try { try {
const { data } = await pagePeerApi(state.queryParams); const { data } = await pagePeerApi(queryParams);
state.records = data.records; records.value = data.records;
state.total = data.total; total.value = data.total;
} finally { } finally {
state.loading = false; loading.value = false;
}
} }
};
/** function handleAdd() {
* Сброс Object.assign(dataForm, { id: undefined, name: "", secret: "", quotaBytes: 0, expiresAt: getMonthLater(), maxDevices: 3, disabled: 0, remark: "" });
*/ dialog.title = t("common.add");
const resetQuery = () => { dialog.editId = 0;
queryFormRef.value.resetFields(); dialog.visible = true;
handleQuery(); }
};
/** async function handleUpdate(row: PeerVo) {
* Сохранение const { data } = await getPeerApi({ id: row.id });
**/ Object.assign(dataForm, data, { secret: "" });
const handleAdd = () => { dialog.title = t("common.update");
state.dialog = { dialog.editId = row.id;
title: t("common.add"), dialog.visible = true;
visible: true, }
};
};
/** async function submitForm() {
* Изменение if (dialog.editId > 0) {
**/ const payload: PeerUpdateDto = { id: dialog.editId, name: dataForm.name, secret: dataForm.secret || undefined, quotaBytes: dataForm.quotaBytes, expiresAt: dataForm.expiresAt, maxDevices: dataForm.maxDevices, disabled: dataForm.disabled, remark: dataForm.remark };
const handleUpdate = async (row: { [key: string]: any }) => { await updatePeerApi(payload);
const id = row.id;
const { data } = await getPeerApi({ id: id });
Object.assign(state.dataForm, data);
quotaTmp.value = data.quota;
dialog.value = {
title: t("common.update"),
visible: true,
};
};
const setQuota = (newQuota: number) => {
state.dataForm.quota = newQuota;
};
/**
* Отправка формы
*/
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
const accountId = state.dataForm.id;
let accountUpdateDto: PeerUpdateDto = { ...state.dataForm };
if (accountId) {
updatePeerApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
} else { } else {
savePeerApi(accountUpdateDto).then(() => { await savePeerApi(dataForm);
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
} }
} dialog.visible = false;
});
};
/**
* Отправка формы отключения пользователя
*/
const submitKickForm = () => {
kickFormRef.value.validate((valid: any) => {
if (valid) {
const params = { ...state.kickForm };
hysteria2KickApi(params).then(() => {
ElMessage.success(t("common.success"));
closeDialogKick();
handleQuery();
});
}
});
};
/**
* Удаление
*/
const handleDelete = (row: { [key: string]: any }) => {
const id = row.id;
const username = row.username;
ElMessageBox.confirm(
t("common.deleteConfirm", { username }),
t("common.warning"),
{
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
type: "warning",
}
)
.then(() => {
deletePeerApi({ id: id }).then(() => {
ElMessage.success(t("common.success"));
handleQuery();
});
})
.catch(() => ElMessage.info(t("common.cancel")));
};
/**
* Принудительное отключение пользователя
* @param row
*/
const handleKick = (row: { [key: string]: any }) => {
state.kickForm.ids = [row.id];
dialogKick.value = {
title: t("peer.kickTip"),
visible: true,
};
};
/**
* Снятие статуса отключения
* @param row
*/
const confirmReleaseKick = (row: { [key: string]: any }) => {
releaseKickPeerApi({ id: row.id }).then(() => {
ElMessage.success(t("peer.releaseSuccess"));
handleQuery();
});
};
/**
* Закрытие окна пользователя
*/
const closeDialog = () => {
dialog.value.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
if (dialog.value.title == t("common.update")) {
resetDataForm();
}
};
/**
* Закрытие окна отключения
*/
const closeDialogKick = () => {
dialogKick.value.visible = false;
kickFormRef.value.resetFields();
kickFormRef.value.clearValidate();
};
/**
* Импорт
*/
const handleImport = (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
let formData = new FormData();
formData.append("file", params.file);
importPeerApi(formData).then(() => {
ElMessage.success(t("common.success"));
});
state.fileList = [];
}
return Promise.resolve();
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
/**
* Экспорт
*/
const handleExport = () => {
exportPeerApi().then((res) => {
const blob = new Blob([res.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = res.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
});
};
const handleNodeUrl = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2UrlDto = {
accountId: row.id,
};
const { data } = await hysteria2UrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleQrCode = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2UrlDto = {
accountId: row.id,
};
const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
const resetTraffic = async (row: { [key: string]: any }) => {
try {
await resetPeerTrafficApi({ id: row.id });
ElMessage.success(t("common.success"));
await handleQuery(); await handleQuery();
} catch (e) {
/* empty */
} }
};
onMounted(() => { async function handleDelete(row: PeerVo) {
// Инициализация списка пользователей await deletePeerApi({ id: row.id });
handleQuery(); await handleQuery();
}); }
async function copyUri(row: PeerVo) {
const { data } = await getPeerClientConfigApi(row.id);
copy(data.url);
}
async function showQr(row: PeerVo) {
const { data } = await getPeerClientConfigApi(row.id);
qrSrc.value = `data:image/png;base64,${data.qrCode}`;
qrDialog.value = true;
}
onMounted(handleQuery);
</script> </script>
+16
View File
@@ -7,6 +7,7 @@ type AccountBo struct {
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 {
+175 -244
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
} }
return accounts, total, nil result = append(result, item)
}
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)
if saveErr != nil {
return vo.PeerVo{}, saveErr
}
return GetPeerVo(id)
}
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
}
if peerDto.Secret != nil && *peerDto.Secret != "" {
digest, err := PeerSecretDigest(*peerDto.Secret)
if err != nil {
return err return err
} }
enc, err := EncryptPeerSecret(*peerDto.Secret)
func DeletePeer(ids []int64) error {
return dao.DeletePeer(ids)
}
func UpdatePeer(account entity.Account) error {
updates := map[string]interface{}{}
if account.Username != nil && *account.Username != "" {
updates["username"] = *account.Username
}
_ = 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 != nil {
if err.Error() == constant.WrongPassword { return err
return false
} }
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)
} }
return accountExports, nil out = append(out, ex)
}
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 == "" {