2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-06 16:21:03 +08:00

feat: 支持绑定域名、IP、UA,close #670

This commit is contained in:
2025-05-14 03:08:31 +08:00
parent 554183caab
commit d27a91570c
22 changed files with 462 additions and 330 deletions

View File

@@ -53,6 +53,10 @@ func initWeb() (*app.Web, error) {
if err != nil {
return nil, err
}
locale, err := bootstrap.NewT(koanf)
if err != nil {
return nil, err
}
logger := bootstrap.NewLog(koanf)
db, err := bootstrap.NewDB(koanf, logger)
if err != nil {
@@ -62,10 +66,6 @@ func initWeb() (*app.Web, error) {
if err != nil {
return nil, err
}
locale, err := bootstrap.NewT(koanf)
if err != nil {
return nil, err
}
cacheRepo := data.NewCacheRepo(db)
queue := bootstrap.NewQueue()
taskRepo := data.NewTaskRepo(locale, db, logger, queue)
@@ -145,7 +145,7 @@ func initWeb() (*app.Web, error) {
http := route.NewHttp(userService, dashboardService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, loader)
wsService := service.NewWsService(locale, koanf, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(middlewares, http, ws)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
if err != nil {
return nil, err
}

2
go.sum
View File

@@ -304,8 +304,6 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sethvargo/go-limiter v1.0.0 h1:JqW13eWEMn0VFv86OKn8wiYJY/m250WoXdrjRV0kLe4=
github.com/sethvargo/go-limiter v1.0.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
github.com/sethvargo/go-limiter v1.0.1-0.20250412144437-fa26982c7e1a h1:CdCoDHVynJVAQWN7ZQrAUOp0SV5TmRwNOSkF5KedDko=
github.com/sethvargo/go-limiter v1.0.1-0.20250412144437-fa26982c7e1a/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=

View File

@@ -4,8 +4,6 @@ import (
"context"
"time"
"github.com/pquerna/otp"
"github.com/tnb-labs/panel/internal/http/request"
)
@@ -22,15 +20,6 @@ const (
SettingKeyMySQLRootPassword SettingKey = "mysql_root_password"
SettingKeyOfflineMode SettingKey = "offline_mode"
SettingKeyAutoUpdate SettingKey = "auto_update"
SettingKeyTwoFA SettingKey = "two_fa"
SettingKeyTwoFASecret SettingKey = "two_fa_secret"
SettingKeyLoginTimeout SettingKey = "login_timeout"
SettingKeyBindDomain SettingKey = "bind_domain"
SettingKeyBindIP SettingKey = "bind_ip"
SettingKeyBindUA SettingKey = "bind_ua"
SettingKeyAPI SettingKey = "api"
SettingKeyAPIKey SettingKey = "api_key"
SettingKeyAPIWhiteList SettingKey = "api_white_list"
)
type Setting struct {
@@ -51,6 +40,4 @@ type SettingRepo interface {
Delete(key SettingKey) error
GetPanelSetting(ctx context.Context) (*request.PanelSetting, error)
UpdatePanelSetting(ctx context.Context, setting *request.PanelSetting) (bool, error)
GenerateTwoFAKey() (*otp.Key, error)
GenerateAPIKey() (string, error)
}

View File

@@ -11,6 +11,7 @@ type User struct {
Username string `gorm:"not null;default:'';unique" json:"username"`
Password string `gorm:"not null;default:''" json:"password"`
Email string `gorm:"not null;default:''" json:"email"`
TwoFA string `gorm:"not null;default:''" json:"two_fa"` // 2FA secret为空表示未开启
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`

View File

@@ -0,0 +1,21 @@
package biz
import (
"time"
)
type UserToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"user_id"`
Token string `gorm:"not null;default:'';unique" json:"token"`
IPs []string `gorm:"not null;default:'[]';serializer:json" json:"ips"`
ExpiredAt time.Time `json:"expired_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserTokenRepo interface {
Create(userID uint, ips []string) (*UserToken, error)
Get(id uint) (*UserToken, error)
Save(user *UserToken) error
}

View File

@@ -8,16 +8,17 @@ import (
"github.com/bddjr/hlfhr"
"github.com/go-chi/chi/v5"
"github.com/knadh/koanf/v2"
"github.com/leonelquinteros/gotext"
"github.com/tnb-labs/panel/internal/http/middleware"
"github.com/tnb-labs/panel/internal/route"
)
func NewRouter(middlewares *middleware.Middlewares, http *route.Http, ws *route.Ws) (*chi.Mux, error) {
func NewRouter(t *gotext.Locale, middlewares *middleware.Middlewares, http *route.Http, ws *route.Ws) (*chi.Mux, error) {
r := chi.NewRouter()
// add middleware
r.Use(middlewares.Globals(r)...)
r.Use(middlewares.Globals(t, r)...)
// add http route
http.Register(r)
// add ws route

View File

@@ -9,9 +9,14 @@ import (
func NewSession(conf *koanf.Koanf, db *gorm.DB) (*sessions.Manager, error) {
// initialize session manager
lifetime := conf.Int("session.lifetime")
// TODO: will remove this fallback in v3
if lifetime == 0 {
lifetime = 120
}
manager, err := sessions.NewManager(&sessions.ManagerOptions{
Key: conf.MustString("app.key"),
Lifetime: conf.MustInt("session.lifetime"),
Lifetime: lifetime,
GcInterval: 5,
DisableDefaultDriver: true,
})

View File

@@ -23,7 +23,6 @@ func NewValidator(conf *koanf.Koanf, db *gorm.DB) *validate.Validation {
validate.Config(func(opt *validate.GlobalOption) {
opt.StopOnError = false
opt.SkipOnEmpty = true
opt.FieldTag = "form"
})
// register global rules

View File

@@ -8,7 +8,6 @@ import (
"sync"
"github.com/go-rat/utils/hash"
"github.com/go-rat/utils/str"
"github.com/knadh/koanf/v2"
"github.com/leonelquinteros/gotext"
"github.com/pquerna/otp"
@@ -219,22 +218,6 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin
if err != nil {
return nil, err
}
twoFA, err := r.GetBool(biz.SettingKeyTwoFA)
if err != nil {
return nil, err
}
bindDomain, err := r.GetSlice(biz.SettingKeyBindDomain)
if err != nil {
return nil, err
}
bindIP, err := r.GetSlice(biz.SettingKeyBindIP)
if err != nil {
return nil, err
}
bindUA, err := r.GetSlice(biz.SettingKeyBindUA)
if err != nil {
return nil, err
}
websitePath, err := r.Get(biz.SettingKeyWebsitePath)
if err != nil {
return nil, err
@@ -243,14 +226,6 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin
if err != nil {
return nil, err
}
api, err := r.GetBool(biz.SettingKeyAPI)
if err != nil {
return nil, err
}
apiWhiteList, err := r.GetSlice(biz.SettingKeyAPIWhiteList)
if err != nil {
return nil, err
}
userID := cast.ToUint(ctx.Value("user_id"))
user := new(biz.User)
@@ -268,27 +243,24 @@ func (r *settingRepo) GetPanelSetting(ctx context.Context) (*request.PanelSettin
}
return &request.PanelSetting{
Name: name,
Channel: channel,
Locale: r.conf.String("app.locale"),
Entrance: r.conf.String("http.entrance"),
OfflineMode: offlineMode,
AutoUpdate: autoUpdate,
TwoFA: twoFA,
Lifetime: uint(r.conf.Int("session.lifetime")),
BindDomain: bindDomain,
BindIP: bindIP,
BindUA: bindUA,
WebsitePath: websitePath,
BackupPath: backupPath,
Username: user.Username,
Email: user.Email,
Port: uint(r.conf.Int("http.port")),
API: api,
APIWhiteList: apiWhiteList,
HTTPS: r.conf.Bool("http.tls"),
Cert: crt,
Key: key,
Name: name,
Channel: channel,
Locale: r.conf.String("app.locale"),
Entrance: r.conf.String("http.entrance"),
OfflineMode: offlineMode,
AutoUpdate: autoUpdate,
Lifetime: uint(r.conf.Int("session.lifetime")),
BindDomain: r.conf.Strings("http.bind_domain"),
BindIP: r.conf.Strings("http.bind_ip"),
BindUA: r.conf.Strings("http.bind_ua"),
WebsitePath: websitePath,
BackupPath: backupPath,
Username: user.Username,
Email: user.Email,
Port: uint(r.conf.Int("http.port")),
HTTPS: r.conf.Bool("http.tls"),
Cert: crt,
Key: key,
}, nil
}
@@ -305,18 +277,6 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P
if err := r.Set(biz.SettingKeyAutoUpdate, cast.ToString(setting.AutoUpdate)); err != nil {
return false, err
}
if err := r.Set(biz.SettingKeyTwoFA, cast.ToString(setting.TwoFA)); err != nil {
return false, err
}
if err := r.SetSlice(biz.SettingKeyBindDomain, setting.BindDomain); err != nil {
return false, err
}
if err := r.SetSlice(biz.SettingKeyBindIP, setting.BindIP); err != nil {
return false, err
}
if err := r.SetSlice(biz.SettingKeyBindUA, setting.BindUA); err != nil {
return false, err
}
if err := r.Set(biz.SettingKeyWebsitePath, setting.WebsitePath); err != nil {
return false, err
}
@@ -388,6 +348,9 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P
config.HTTP.Port = setting.Port
config.HTTP.Entrance = setting.Entrance
config.HTTP.TLS = setting.HTTPS
config.HTTP.BindDomain = setting.BindDomain
config.HTTP.BindIP = setting.BindIP
config.HTTP.BindUA = setting.BindUA
config.Session.Lifetime = setting.Lifetime
// 放行端口
@@ -420,8 +383,9 @@ func (r *settingRepo) UpdatePanelSetting(ctx context.Context, setting *request.P
return restartFlag, nil
}
// GenerateTwoFAKey 生成两步验证密钥
func (r *settingRepo) GenerateTwoFAKey() (*otp.Key, error) {
// GetTwoFA 生成两步验证密钥
// TODO: 即将废弃
func (r *settingRepo) GetTwoFA() (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "RatPanel",
AccountName: "admin",
@@ -432,24 +396,5 @@ func (r *settingRepo) GenerateTwoFAKey() (*otp.Key, error) {
return nil, err
}
if err = r.Set(biz.SettingKeyTwoFASecret, key.Secret()); err != nil {
return nil, err
}
return key, nil
}
// GenerateAPIKey 生成API密钥
func (r *settingRepo) GenerateAPIKey() (string, error) {
key := str.Random(32)
hashed, err := hash.NewArgon2id().Make(key)
if err != nil {
return "", err
}
if err = r.Set(biz.SettingKeyAPIKey, hashed); err != nil {
return "", err
}
return key, nil
}

View File

@@ -1,17 +1,22 @@
package middleware
import (
"net"
"net/http"
"slices"
"strings"
"github.com/go-rat/chix"
"github.com/go-rat/sessions"
"github.com/knadh/koanf/v2"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"github.com/tnb-labs/panel/pkg/punycode"
)
// Entrance 确保通过正确的入口访问
func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handler) http.Handler {
func Entrance(t *gotext.Locale, conf *koanf.Koanf, session *sessions.Manager) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := session.GetSession(r)
@@ -26,6 +31,50 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl
}
entrance := conf.String("http.entrance")
// 情况一设置了绑定域名、IP、UA且请求不符合要求返回错误
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
if strings.Contains(host, "xn--") {
if decoded, err := punycode.DecodeDomain(host); err == nil {
host = decoded
}
}
if len(conf.Strings("http.bind_domain")) > 0 && !slices.Contains(conf.Strings("http.bind_domain"), host) {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusTeapot)
render.JSON(chix.M{
"message": t.Get("invalid request domain: %s", r.Host),
})
return
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if len(conf.Strings("http.bind_ip")) > 0 && !slices.Contains(conf.Strings("http.bind_ip"), ip) {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusTeapot)
render.JSON(chix.M{
"message": t.Get("invalid request ip: %s", r.RemoteAddr),
})
return
}
if len(conf.Strings("http.bind_ua")) > 0 && !slices.Contains(conf.Strings("http.bind_ua"), r.UserAgent()) {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusTeapot)
render.JSON(chix.M{
"message": t.Get("invalid request user agent: %s", r.UserAgent()),
})
return
}
// 情况二:请求路径与入口路径相同,标记通过验证并重定向到登录页面
if strings.TrimSuffix(r.URL.Path, "/") == strings.TrimSuffix(entrance, "/") {
sess.Put("verify_entrance", true)
render := chix.NewRender(w, r)
@@ -34,6 +83,14 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl
return
}
// 情况三通过APIKey+入口路径访问,重写请求路径并跳过验证
if strings.HasPrefix(r.URL.Path, entrance) && r.Header.Get("Authorization") != "" {
r.URL.Path = strings.TrimPrefix(r.URL.Path, entrance)
next.ServeHTTP(w, r)
return
}
// 情况三:非调试模式且未通过验证的请求,返回错误
if !conf.Bool("app.debug") &&
!cast.ToBool(sess.Get("verify_entrance", false)) &&
r.URL.Path != "/robots.txt" {
@@ -41,7 +98,7 @@ func Entrance(conf *koanf.Koanf, session *sessions.Manager) func(next http.Handl
defer render.Release()
render.Status(http.StatusTeapot)
render.JSON(chix.M{
"message": "请通过正确的入口访问",
"message": t.Get("invalid access entrance"),
})
return
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/golang-cz/httplog"
"github.com/google/wire"
"github.com/knadh/koanf/v2"
"github.com/leonelquinteros/gotext"
"github.com/tnb-labs/panel/internal/biz"
)
@@ -34,7 +35,7 @@ func NewMiddlewares(conf *koanf.Koanf, log *slog.Logger, session *sessions.Manag
}
// Globals is a collection of global middleware that will be applied to every request.
func (r *Middlewares) Globals(mux *chi.Mux) []func(http.Handler) http.Handler {
func (r *Middlewares) Globals(t *gotext.Locale, mux *chi.Mux) []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
sessionmiddleware.StartSession(r.session),
//middleware.SupressNotFound(mux),// bug https://github.com/go-chi/chi/pull/940
@@ -46,9 +47,9 @@ func (r *Middlewares) Globals(mux *chi.Mux) []func(http.Handler) http.Handler {
LogRequestHeaders: []string{"User-Agent"},
}),
middleware.Recoverer,
Status,
Entrance(r.conf, r.session),
MustLogin(r.session),
MustInstall(r.app),
Status(t),
Entrance(t, r.conf, r.session),
MustLogin(t, r.session),
MustInstall(t, r.app),
}
}

View File

@@ -1,17 +1,17 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/go-rat/chix"
"github.com/leonelquinteros/gotext"
"github.com/tnb-labs/panel/internal/biz"
)
// MustInstall 确保已安装应用
func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler {
func MustInstall(t *gotext.Locale, app biz.AppRepo) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var slugs []string
@@ -26,7 +26,7 @@ func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler {
defer render.Release()
render.Status(http.StatusForbidden)
render.JSON(chix.M{
"message": "应用不存在",
"message": t.Get("app not found"),
})
return
}
@@ -45,7 +45,7 @@ func MustInstall(app biz.AppRepo) func(next http.Handler) http.Handler {
defer render.Release()
render.Status(http.StatusForbidden)
render.JSON(chix.M{
"message": fmt.Sprintf("应用 %s 未安装", slugs),
"message": t.Get("app %s not installed", slugs),
})
return
}

View File

@@ -1,21 +1,29 @@
package middleware
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"slices"
"strings"
"time"
"github.com/go-rat/chix"
"github.com/go-rat/sessions"
"github.com/go-rat/utils/str"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
)
// MustLogin 确保已登录
func MustLogin(session *sessions.Manager) func(next http.Handler) http.Handler {
func MustLogin(t *gotext.Locale, session *sessions.Manager) func(next http.Handler) http.Handler {
// 白名单
whiteList := []string{
"/api/user/key",
@@ -43,45 +51,101 @@ func MustLogin(session *sessions.Manager) func(next http.Handler) http.Handler {
return
}
if sess.Missing("user_id") {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": "会话已过期,请重新登录",
})
return
userID := uint(0)
if r.Header.Get("Authorization") != "" {
signature := strings.TrimPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 ")
// 步骤一:构造规范化请求
body, err := io.ReadAll(r.Body)
if err != nil {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusInternalServerError)
render.JSON(chix.M{
"message": err.Error(),
})
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, r.URL.Path, r.URL.Query().Encode(), str.SHA256(string(body)))
// 步骤二:构造待签名字符串
stringToSign := fmt.Sprintf("%s\n%d\n%s", "HMAC-SHA256", cast.ToInt64(r.Header.Get("X-Timestamp")), str.SHA256(canonicalRequest))
// 步骤三:计算签名
validSignature := hmacsha256(stringToSign, cast.ToString(sess.Get("api_secret")))
// 步骤四:验证签名
if subtle.ConstantTimeCompare([]byte(signature), []byte(validSignature)) != 1 {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": t.Get("invalid api signature"),
})
return
}
timestamp := cast.ToInt64(r.Header.Get("X-Timestamp"))
if timestamp == 0 || timestamp < (time.Now().Unix()-60) {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": t.Get("api signature expired"),
})
return
}
// 步骤五:验证通过
userID = 1
} else {
if sess.Missing("user_id") {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": t.Get("session expired, please login again"),
})
return
}
safeLogin := cast.ToBool(sess.Get("safe_login"))
if safeLogin {
safeClientHash := cast.ToString(sess.Get("safe_client"))
ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
clientHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))
if safeClientHash != clientHash || safeClientHash == "" {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": t.Get("client ip/ua changed, please login again"),
})
return
}
}
userID = cast.ToUint(sess.Get("user_id"))
}
userID := cast.ToUint(sess.Get("user_id"))
if userID == 0 {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": "会话无效,请重新登录",
"message": t.Get("invalid user id, please login again"),
})
return
}
safeLogin := cast.ToBool(sess.Get("safe_login"))
if safeLogin {
safeClientHash := cast.ToString(sess.Get("safe_client"))
ip, _, _ := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
clientHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))
if safeClientHash != clientHash || safeClientHash == "" {
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusUnauthorized)
render.JSON(chix.M{
"message": "客户端IP/UA变化请重新登录",
})
return
}
}
r = r.WithContext(context.WithValue(r.Context(), "user_id", userID)) // nolint:staticcheck
next.ServeHTTP(w, r)
})
}
}
func hmacsha256(data string, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}

View File

@@ -4,48 +4,51 @@ import (
"net/http"
"github.com/go-rat/chix"
"github.com/leonelquinteros/gotext"
"github.com/tnb-labs/panel/internal/app"
)
// Status 检查程序状态
func Status(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch app.Status {
case app.StatusUpgrade:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusServiceUnavailable)
render.JSON(chix.M{
"message": "面板更新中,请稍后刷新",
})
return
case app.StatusMaintain:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusServiceUnavailable)
render.JSON(chix.M{
"message": "面板正在运行维护任务,请稍后刷新",
})
return
case app.StatusClosed:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusForbidden)
render.JSON(chix.M{
"message": "面板已关闭",
})
return
case app.StatusFailed:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusInternalServerError)
render.JSON(chix.M{
"message": "面板运行出错,请检查排除或联系支持",
})
return
default:
next.ServeHTTP(w, r)
}
})
func Status(t *gotext.Locale) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch app.Status {
case app.StatusUpgrade:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusServiceUnavailable)
render.JSON(chix.M{
"message": t.Get("panel is upgrading, please refresh later"),
})
return
case app.StatusMaintain:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusServiceUnavailable)
render.JSON(chix.M{
"message": t.Get("panel is maintaining, please refresh later"),
})
return
case app.StatusClosed:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusForbidden)
render.JSON(chix.M{
"message": t.Get("panel is closed"),
})
return
case app.StatusFailed:
render := chix.NewRender(w)
defer render.Release()
render.Status(http.StatusInternalServerError)
render.JSON(chix.M{
"message": t.Get("panel run error, please check or contact support"),
})
return
default:
next.ServeHTTP(w, r)
}
})
}
}

View File

@@ -1,26 +1,34 @@
package request
import "net/http"
type PanelSetting struct {
Name string `json:"name" validate:"required"`
Channel string `json:"channel" validate:"required|in:stable,beta"`
Locale string `json:"locale" validate:"required"`
Entrance string `json:"entrance" validate:"required"`
OfflineMode bool `json:"offline_mode"`
AutoUpdate bool `json:"auto_update"`
TwoFA bool `json:"two_fa"`
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
BindDomain []string `json:"bind_domain"`
BindIP []string `json:"bind_ip"`
BindUA []string `json:"bind_ua"`
WebsitePath string `json:"website_path" validate:"required"`
BackupPath string `json:"backup_path" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"password"`
Email string `json:"email" validate:"required"`
Port uint `json:"port" validate:"required|min:1|max:65535"`
API bool `json:"api"`
APIWhiteList []string `json:"api_white_list"`
HTTPS bool `json:"https"`
Cert string `json:"cert" validate:"required"`
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required"`
Channel string `json:"channel" validate:"required|in:stable,beta"`
Locale string `json:"locale" validate:"required"`
Entrance string `json:"entrance" validate:"required"`
OfflineMode bool `json:"offline_mode"`
AutoUpdate bool `json:"auto_update"`
TwoFA bool `json:"two_fa"`
Lifetime uint `json:"lifetime" validate:"required|min:10|max:43200"` // 登录超时,单位:分
BindDomain []string `json:"bind_domain"`
BindIP []string `json:"bind_ip"`
BindUA []string `json:"bind_ua"`
WebsitePath string `json:"website_path" validate:"required"`
BackupPath string `json:"backup_path" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"password"`
Email string `json:"email" validate:"required"`
Port uint `json:"port" validate:"required|min:1|max:65535"`
HTTPS bool `json:"https"`
Cert string `json:"cert" validate:"required"`
Key string `json:"key" validate:"required"`
}
func (r *PanelSetting) Rules(_ *http.Request) map[string]string {
return map[string]string{
"BindDomain.*": "required",
"BindIP.*": "required|ip",
"BindUA.*": "required",
}
}

View File

@@ -93,4 +93,26 @@ func init() {
return tx.Migrator().DropColumn(&biz.Cert{}, "script")
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20250514-user-two-fa",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&biz.User{},
)
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropColumn(&biz.User{}, "two_fa")
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20250514-user-token",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&biz.UserToken{},
)
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(&biz.UserToken{})
},
})
}

View File

@@ -826,15 +826,6 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error {
{Key: biz.SettingKeyWebsitePath, Value: filepath.Join(app.Root, "wwwroot")},
{Key: biz.SettingKeyOfflineMode, Value: "false"},
{Key: biz.SettingKeyAutoUpdate, Value: "true"},
{Key: biz.SettingKeyTwoFA, Value: "false"},
{Key: biz.SettingKeyTwoFASecret, Value: ""},
{Key: biz.SettingKeyLoginTimeout, Value: "720"},
{Key: biz.SettingKeyBindDomain, Value: "[]"},
{Key: biz.SettingKeyBindIP, Value: "[]"},
{Key: biz.SettingKeyBindUA, Value: "[]"},
{Key: biz.SettingKeyAPI, Value: "false"},
{Key: biz.SettingKeyAPIKey, Value: ""},
{Key: biz.SettingKeyAPIWhiteList, Value: "[]"},
}
if err := s.db.Create(&settings).Error; err != nil {
return errors.New(s.t.Get("Initialization failed: %v", err))

View File

@@ -17,10 +17,13 @@ type PanelAppConfig struct {
}
type PanelHTTPConfig struct {
Debug bool `yaml:"debug"`
Port uint `yaml:"port"`
Entrance string `yaml:"entrance"`
TLS bool `yaml:"tls"`
Debug bool `yaml:"debug"`
Port uint `yaml:"port"`
Entrance string `yaml:"entrance"`
TLS bool `yaml:"tls"`
BindDomain []string `yaml:"bind_domain"`
BindIP []string `yaml:"bind_ip"`
BindUA []string `yaml:"bind_ua"`
}
type PanelDatabaseConfig struct {

View File

@@ -1,24 +1,74 @@
<script setup lang="ts">
import setting from '@/api/panel/setting'
defineOptions({
name: 'setting-index'
})
import TheIcon from '@/components/custom/TheIcon.vue'
import { useThemeStore } from '@/store'
import SettingBase from '@/views/setting/SettingBase.vue'
import SettingHttps from '@/views/setting/SettingHttps.vue'
import SettingSafe from '@/views/setting/SettingSafe.vue'
import { NButton } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const themeStore = useThemeStore()
const currentTab = ref('base')
const { data: model } = useRequest(setting.list, {
initialData: {
name: '',
channel: 'stable',
locale: 'en',
channel: 'stable',
username: '',
password: '',
email: '',
port: 8888,
entrance: '',
offline_mode: false,
two_fa: false,
lifetime: 0,
bind_domain: [],
bind_ip: [],
bind_ua: [],
website_path: '',
backup_path: '',
https: false,
cert: '',
key: ''
}
})
const handleSave = () => {
useRequest(setting.update(model.value)).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
if (model.value.locale !== themeStore.locale) {
themeStore.setLocale(model.value.locale)
window.$message.info($gettext('Panel is restarting, page will refresh in 3 seconds'))
setTimeout(() => {
window.location.reload()
}, 3000)
}
})
}
</script>
<template>
<common-page show-footer>
<template #action>
<n-button type="primary" @click="handleSave">
<TheIcon :size="18" icon="material-symbols:save-outline" />
{{ $gettext('Save') }}
</n-button>
</template>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="base" :tab="$gettext('Basic')">
<setting-base />
<setting-base v-model:model="model" />
</n-tab-pane>
<n-tab-pane name="https" tab="HTTPS">
<setting-https />
<n-tab-pane name="safe" :tab="$gettext('Safe')">
<setting-safe v-model:model="model" />
</n-tab-pane>
</n-tabs>
</common-page>

View File

@@ -1,29 +1,10 @@
<script setup lang="ts">
import setting from '@/api/panel/setting'
import { useThemeStore } from '@/store'
import { locales as availableLocales } from '@/utils'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const themeStore = useThemeStore()
const { data: model } = useRequest(setting.list, {
initialData: {
name: '',
locale: '',
username: '',
password: '',
email: '',
port: 8888,
entrance: '',
offline_mode: false,
website_path: '',
backup_path: '',
https: false,
cert: '',
key: ''
}
})
const model = defineModel<any>('model', { type: Object, required: true })
const locales = computed(() => {
return Object.entries(availableLocales).map(([code, name]: [string, string]) => {
@@ -34,18 +15,16 @@ const locales = computed(() => {
})
})
const handleSave = () => {
useRequest(setting.update(model.value)).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
if (model.value.locale !== themeStore.locale) {
themeStore.setLocale(model.value.locale)
window.$message.info($gettext('Panel is restarting, page will refresh in 3 seconds'))
setTimeout(() => {
window.location.reload()
}, 3000)
}
})
}
const channels = [
{
label: $gettext('Stable'),
value: 'stable'
},
{
label: $gettext('Beta'),
value: 'beta'
}
]
</script>
<template>
@@ -64,6 +43,9 @@ const handleSave = () => {
<n-form-item :label="$gettext('Language')">
<n-select v-model:value="model.locale" :options="locales"> </n-select>
</n-form-item>
<n-form-item :label="$gettext('Update Channel')">
<n-select v-model:value="model.channel" :options="channels"> </n-select>
</n-form-item>
<n-form-item :label="$gettext('Username')">
<n-input v-model:value="model.username" :placeholder="$gettext('admin')" />
</n-form-item>
@@ -74,16 +56,7 @@ const handleSave = () => {
<n-input v-model:value="model.email" :placeholder="$gettext('admin@yourdomain.com')" />
</n-form-item>
<n-form-item :label="$gettext('Port')">
<n-input-number v-model:value="model.port" :placeholder="$gettext('8888')" />
</n-form-item>
<n-form-item :label="$gettext('Access Entrance')">
<n-input v-model:value="model.entrance" :placeholder="$gettext('admin')" />
</n-form-item>
<n-form-item :label="$gettext('Offline Mode')">
<n-switch v-model:value="model.offline_mode" />
</n-form-item>
<n-form-item :label="$gettext('Auto Update')">
<n-switch v-model:value="model.auto_update" />
<n-input-number v-model:value="model.port" :placeholder="$gettext('8888')" w-full />
</n-form-item>
<n-form-item :label="$gettext('Default Website Directory')">
<n-input v-model:value="model.website_path" :placeholder="$gettext('/www/wwwroot')" />
@@ -93,9 +66,6 @@ const handleSave = () => {
</n-form-item>
</n-form>
</n-space>
<n-button type="primary" @click="handleSave">
{{ $gettext('Save') }}
</n-button>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import setting from '@/api/panel/setting'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const { data: model } = useRequest(setting.list, {
initialData: {
name: '',
locale: '',
username: '',
password: '',
email: '',
port: 8888,
entrance: '',
offline_mode: false,
website_path: '',
backup_path: '',
https: false,
cert: '',
key: ''
}
})
const handleSave = () => {
useRequest(setting.update(model.value)).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
})
}
</script>
<template>
<n-space vertical>
<n-alert type="warning">
{{
$gettext(
'Incorrect certificates may cause the panel to be inaccessible. Please proceed with caution!'
)
}}</n-alert
>
<n-form>
<n-form-item :label="$gettext('Panel HTTPS')">
<n-switch v-model:value="model.https" />
</n-form-item>
<n-form-item v-if="model.https" :label="$gettext('Certificate')">
<n-input
v-model:value="model.cert"
type="textarea"
:autosize="{ minRows: 10, maxRows: 15 }"
/>
</n-form-item>
<n-form-item v-if="model.https" :label="$gettext('Private Key')">
<n-input
v-model:value="model.key"
type="textarea"
:autosize="{ minRows: 10, maxRows: 15 }"
/>
</n-form-item>
</n-form>
</n-space>
<n-button type="primary" @click="handleSave">
{{ $gettext('Save') }}
</n-button>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const model = defineModel<any>('model', { type: Object, required: true })
</script>
<template>
<n-space vertical>
<n-form>
<n-form-item :label="$gettext('Login Timeout')">
<n-input-number
v-model:value="model.lifetime"
:placeholder="$gettext('120')"
:min="10"
:max="43200"
w-full
>
<template #suffix>
{{ $gettext('minutes') }}
</template>
</n-input-number>
</n-form-item>
<n-form-item :label="$gettext('Access Entrance')">
<n-input v-model:value="model.entrance" :placeholder="$gettext('admin')" />
</n-form-item>
<n-form-item :label="$gettext('Bind Domain')">
<n-dynamic-input
v-model:value="model.bind_domain"
placeholder="example.com"
show-sort-button
/>
</n-form-item>
<n-form-item :label="$gettext('Bind IP')">
<n-dynamic-input v-model:value="model.bind_ip" placeholder="127.0.0.1" show-sort-button />
</n-form-item>
<n-form-item :label="$gettext('Bind UA')">
<n-dynamic-input
v-model:value="model.bind_ua"
placeholder="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36"
show-sort-button
/>
</n-form-item>
<n-form-item :label="$gettext('Offline Mode')">
<n-switch v-model:value="model.offline_mode" />
</n-form-item>
<n-form-item :label="$gettext('Auto Update')">
<n-switch v-model:value="model.auto_update" />
</n-form-item>
<n-form-item :label="$gettext('Panel HTTPS')">
<n-switch v-model:value="model.https" />
</n-form-item>
<n-form-item v-if="model.https" :label="$gettext('Certificate')">
<n-input
v-model:value="model.cert"
type="textarea"
:autosize="{ minRows: 10, maxRows: 15 }"
/>
</n-form-item>
<n-form-item v-if="model.https" :label="$gettext('Private Key')">
<n-input
v-model:value="model.key"
type="textarea"
:autosize="{ minRows: 10, maxRows: 15 }"
/>
</n-form-item>
</n-form>
</n-space>
</template>
<style scoped lang="scss"></style>