Полный продакшен-рефактор 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)
}
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 (
"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
View File
@@ -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
}
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
}
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(data, c)
}
+4 -4
View File
@@ -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
View File
@@ -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: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
+100 -62
View File
@@ -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);
-8
View File
@@ -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",
});
}
-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 { 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",
+32 -43
View File
@@ -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
View File
@@ -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']
+1 -2
View File
@@ -8,8 +8,7 @@ declare module "*.vue" {
}
// TypeScript-подсказки для переменных окружения
interface ImportMetaEnv {
}
type ImportMetaEnv = Record<string, string | boolean | undefined>;
interface ImportMeta {
readonly env: ImportMetaEnv;
+105 -832
View File
@@ -1,271 +1,46 @@
<template>
<div class="app-container">
<div class="search">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('peer.remark')" prop="remark">
<el-input
v-model="queryParams.remark"
:placeholder="$t('peer.remark')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
<el-form :model="queryParams" :inline="true" class="mb-3">
<el-form-item :label="$t('peer.username')">
<el-input v-model="queryParams.name" clearable style="width: 220px" />
</el-form-item>
<el-form-item :label="$t('peer.username')" prop="username">
<el-input
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 :label="$t('peer.remark')">
<el-input v-model="queryParams.remark" clearable style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button :icon="Refresh" @click="resetQuery"
>{{ $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-button type="primary" @click="handleQuery">{{ $t("common.search") }}</el-button>
</el-form-item>
</el-form>
</div>
<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-column
key="id"
:label="$t('common.id')"
align="center"
prop="id"
/>
<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 prop="id" :label="$t('common.id')" width="80" />
<el-table-column prop="name" :label="$t('peer.username')" min-width="180" />
<el-table-column prop="remark" :label="$t('peer.remark')" min-width="200" />
<el-table-column :label="$t('peer.quota')" min-width="140">
<template #default="scope">{{ formatBytes(scope.row.quotaBytes) }}</template>
</el-table-column>
<el-table-column
key="download"
:label="$t('peer.download')"
align="center"
prop="download"
>
<template #default="scope">
{{ formatBytes(scope.row.download) }}
</template>
<el-table-column :label="$t('peer.download')" min-width="140">
<template #default="scope">{{ formatBytes(scope.row.downloadBytes) }}</template>
</el-table-column>
<el-table-column
key="upload"
:label="$t('peer.upload')"
align="center"
prop="upload"
>
<template #default="scope">
{{ formatBytes(scope.row.upload) }}
</template>
<el-table-column :label="$t('peer.upload')" min-width="140">
<template #default="scope">{{ formatBytes(scope.row.uploadBytes) }}</template>
</el-table-column>
<el-table-column
key="online"
: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 :label="$t('peer.expireTime')" min-width="170">
<template #default="scope">{{ timestampToDateTime(scope.row.expiresAt) }}</template>
</el-table-column>
<el-table-column
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"
>
<el-table-column :label="$t('common.operate')" width="320">
<template #default="scope">
{{ calculateTimeDifference(scope.row.kickUtilTime) }}
</template>
</el-table-column>
<el-table-column
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>
<el-button link type="primary" @click="copyUri(scope.row)">URI</el-button>
<el-button link type="primary" @click="showQr(scope.row)">QR</el-button>
<el-button link type="primary" @click="handleUpdate(scope.row)">{{ $t("common.edit") }}</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">{{ $t("common.delete") }}</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
:total="total"
@@ -275,622 +50,120 @@
/>
</el-card>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="620px"
append-to-body
@close="closeDialog"
>
<el-form
ref="dataFormRef"
: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-dialog v-model="dialog.visible" :title="dialog.title" width="620px">
<el-form :model="dataForm" label-width="140px">
<el-form-item label="Name"><el-input v-model="dataForm.name" /></el-form-item>
<el-form-item label="Remark"><el-input v-model="dataForm.remark" /></el-form-item>
<el-form-item label="Secret"><el-input v-model="dataForm.secret" show-password /></el-form-item>
<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-item label="Max devices"><el-input-number v-model="dataForm.maxDevices" :min="1" /></el-form-item>
<el-form-item label="Disabled"><el-switch v-model="disabledBool" /></el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
<el-button type="primary" @click="submitForm">{{ $t("common.confirm") }}</el-button>
</template>
</el-dialog>
<el-dialog
:title="dialogKick.title"
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 v-model="qrDialog" title="QR" width="420px">
<el-image style="width:300px;height:300px" :src="qrSrc" />
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import {
KickPeerForm,
PeerForm,
PeerPageDto,
PeerUpdateDto,
PeerVo,
} from "@/api/peer/types";
import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard";
import { formatBytes } from "@/utils/byte";
import { getMonthLater, timestampToDateTime } from "@/utils/time";
import {
deletePeerApi,
exportPeerApi,
getPeerApi,
importPeerApi,
getPeerClientConfigApi,
pagePeerApi,
releaseKickPeerApi,
resetPeerTrafficApi,
savePeerApi,
updatePeerApi,
} from "@/api/peer";
import { Search, Plus, Refresh } from "@element-plus/icons-vue";
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";
import { PeerPageDto, PeerSaveDto, PeerUpdateDto, PeerVo } from "@/api/peer/types";
const { t } = useI18n();
const queryFormRef = ref(ElForm); // Форма поиска
const dataFormRef = ref(ElForm); // Форма пользователя
const kickFormRef = ref(ElForm); // Форма отключения пользователя
const loading = ref(false);
const total = ref(0);
const records = ref<PeerVo[]>([]);
const qrDialog = ref(false);
const qrSrc = ref("");
const dataFormAddRules = {
remark: [
{
min: 0,
max: 32,
message: "Remark format is incorrect",
trigger: ["change", "blur"],
},
],
username: [
{
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 queryParams = reactive<PeerPageDto>({ pageNum: 1, pageSize: 10, name: undefined, remark: undefined, disabled: undefined });
const dialog = reactive({ visible: false, title: "", editId: 0 });
const dataForm = reactive<PeerSaveDto & { id?: number }>({
name: "",
secret: "",
quotaBytes: 0,
expiresAt: getMonthLater(),
maxDevices: 3,
disabled: 0,
remark: "",
});
const {
loading,
total,
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,
const disabledBool = computed({
get: () => dataForm.disabled === 1,
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
});
quotaTmp.value = 0;
};
/**
* Поиск
*/
const handleQuery = async () => {
state.loading = true;
async function handleQuery() {
loading.value = true;
try {
const { data } = await pagePeerApi(state.queryParams);
state.records = data.records;
state.total = data.total;
const { data } = await pagePeerApi(queryParams);
records.value = data.records;
total.value = data.total;
} finally {
state.loading = false;
loading.value = false;
}
}
};
/**
* Сброс
*/
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
function handleAdd() {
Object.assign(dataForm, { id: undefined, name: "", secret: "", quotaBytes: 0, expiresAt: getMonthLater(), maxDevices: 3, disabled: 0, remark: "" });
dialog.title = t("common.add");
dialog.editId = 0;
dialog.visible = true;
}
/**
* Сохранение
**/
const handleAdd = () => {
state.dialog = {
title: t("common.add"),
visible: true,
};
};
async function handleUpdate(row: PeerVo) {
const { data } = await getPeerApi({ id: row.id });
Object.assign(dataForm, data, { secret: "" });
dialog.title = t("common.update");
dialog.editId = row.id;
dialog.visible = true;
}
/**
* Изменение
**/
const handleUpdate = async (row: { [key: string]: any }) => {
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();
});
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 };
await updatePeerApi(payload);
} else {
savePeerApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
await savePeerApi(dataForm);
}
}
});
};
/**
* Отправка формы отключения пользователя
*/
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"));
dialog.visible = false;
await handleQuery();
} catch (e) {
/* empty */
}
};
onMounted(() => {
// Инициализация списка пользователей
handleQuery();
});
async function handleDelete(row: PeerVo) {
await deletePeerApi({ id: row.id });
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>
+16
View File
@@ -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"`
}
-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"
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"
-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"`
}
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"`
}
+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"`
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"`
-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.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) {
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
View File
@@ -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)
}
}
+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]
}
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 {
+175 -244
View File
@@ -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)
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
}
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)
}
enc, err := EncryptPeerSecret(*peerDto.Secret)
if err != nil {
if err.Error() == constant.WrongPassword {
return false
return err
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
}
return true
if peerDto.QuotaBytes != nil {
updates["quota_bytes"] = *peerDto.QuotaBytes
}
if peerDto.ExpiresAt != nil {
updates["expires_at"] = *peerDto.ExpiresAt
}
if peerDto.MaxDevices != nil {
updates["max_devices"] = *peerDto.MaxDevices
}
if peerDto.Disabled != nil {
updates["disabled"] = *peerDto.Disabled
}
if peerDto.Remark != nil {
updates["remark"] = *peerDto.Remark
}
return dao.UpdatePeer([]int64{id}, updates)
}
func GetPeer(id int64) (entity.Account, error) {
peer, err := dao.GetPeer("id = ?", id)
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) {
p, err := dao.GetPeer("id = ?", id)
if err != nil {
return entity.Account{}, err
return vo.PeerVo{}, err
}
role := "user"
return entity.Account{
BaseEntity: peer.BaseEntity,
Username: peer.Name,
ConPass: peer.SecretCiphertext,
Quota: peer.QuotaBytes,
Download: peer.DownloadBytes,
Upload: peer.UploadBytes,
ExpireTime: peer.ExpiresAt,
DeviceNo: peer.MaxDevices,
KickUtilTime: peer.BannedUntil,
ConAt: peer.LastConnectionAt,
Deleted: peer.Disabled,
Remark: peer.Remark,
Role: &role,
return vo.PeerVo{
BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime},
Name: strVal(p.Name),
Remark: strVal(p.Remark),
AuthId: strVal(p.AuthId),
QuotaBytes: int64Val(p.QuotaBytes),
DownloadBytes: int64Val(p.DownloadBytes),
UploadBytes: int64Val(p.UploadBytes),
ExpiresAt: int64Val(p.ExpiresAt),
MaxDevices: int64Val(p.MaxDevices),
Disabled: int64Val(p.Disabled),
BannedUntil: int64Val(p.BannedUntil),
LastConnectionAt: int64Val(p.LastConnectionAt),
}, nil
}
func GetAdminAccount(id int64) (entity.AdminUser, error) {
return dao.GetAdminUser("id = ?", id)
func ResetPeerTraffic(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}) }
func ReleaseKickPeer(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0}) }
func KickPeer(id int64, bannedUntil int64) error {
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
return err
}
return Hysteria2Kick([]int64{id}, bannedUntil)
}
func ListExportPeer() ([]bo.AccountExport, error) {
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
url, err := Hysteria2Url(id)
if err != nil {
return vo.PeerClientConfigVo{}, err
}
qrCode, err := qrcode.Encode(url, qrcode.Medium, 300)
if err != nil {
return vo.PeerClientConfigVo{}, err
}
return vo.PeerClientConfigVo{Url: url, QrCode: qrCode}, nil
}
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
peers, err := dao.ListPeer("1=1")
if err != nil {
return nil, errors.New(constant.SysError)
}
var accountExports []bo.AccountExport
out := make([]bo.PeerExport, 0, len(peers))
for _, item := range peers {
role := "user"
conPass := ""
if item.SecretCiphertext != nil {
conPass = *item.SecretCiphertext
ex := bo.PeerExport{
Id: int64Val(item.Id),
Name: strVal(item.Name),
Remark: strVal(item.Remark),
QuotaBytes: int64Val(item.QuotaBytes),
DownloadBytes: int64Val(item.DownloadBytes),
UploadBytes: int64Val(item.UploadBytes),
ExpiresAt: int64Val(item.ExpiresAt),
MaxDevices: int64Val(item.MaxDevices),
Disabled: int64Val(item.Disabled),
BannedUntil: int64Val(item.BannedUntil),
LastConnectionAt: int64Val(item.LastConnectionAt),
}
accountExport := bo.AccountExport{
Id: *item.Id,
Username: *item.Name,
Pass: "",
ConPass: conPass,
Quota: *item.QuotaBytes,
Download: *item.DownloadBytes,
Upload: *item.UploadBytes,
ExpireTime: *item.ExpiresAt,
DeviceNo: *item.MaxDevices,
KickUtilTime: *item.BannedUntil,
Role: role,
Deleted: *item.Disabled,
CreateTime: *item.CreateTime,
UpdateTime: *item.UpdateTime,
LoginAt: 0,
ConAt: *item.LastConnectionAt,
Remark: *item.Remark,
if includeSecrets && item.SecretEncrypted != nil {
if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
ex.Secret = dec
}
accountExports = append(accountExports, accountExport)
}
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,
})
}
+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
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 == "" {