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