2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 05:31:44 +08:00

Add redirect and advanced settings tabs to website editor (#1267)

* Initial plan

* Add redirect tab to website editor with drag-and-drop support

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* Add advanced settings tab with rate limiting and basic auth

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* Add htpasswd file support for basic auth (nginx and apache compatible)

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* Fix code review issues: prevent duplicate usernames in basic auth

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: bug

* fix: bug

* feat: 支持real ip设置

* feat: 支持real ip设置

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-22 06:32:13 +08:00
committed by GitHub
parent 0e958cbd78
commit a386dc3a2a
13 changed files with 821 additions and 109 deletions

7
go.sum
View File

@@ -164,8 +164,6 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -320,7 +318,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -433,8 +430,6 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -508,8 +503,6 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=

View File

@@ -1,6 +1,7 @@
package data
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -16,6 +17,7 @@ import (
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/app"
@@ -207,6 +209,17 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
setting.Proxies = proxyVhost.Proxies()
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
setting.Redirects = redirectVhost.Redirects()
}
// 高级设置(限流限速、真实 IP、基本认证
setting.RateLimit = vhost.RateLimit()
setting.RealIP = vhost.RealIP()
// 读取基本认证用户列表(从 htpasswd 文件)
setting.BasicAuth = r.readBasicAuthUsers(website.Name)
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
setting.CustomConfigs = r.getCustomConfigs(configDir)
@@ -655,6 +668,51 @@ func (r *websiteRepo) Update(ctx context.Context, req *request.WebsiteUpdate) er
}
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
if err = redirectVhost.SetRedirects(req.Redirects); err != nil {
return err
}
}
// 高级设置(限流限速、真实 IP、基本认证
if req.RateLimit != nil {
if err = vhost.SetRateLimit(req.RateLimit); err != nil {
return err
}
} else {
if err = vhost.ClearRateLimit(); err != nil {
return err
}
}
// 真实 IP 配置
if req.RealIP != nil {
if err = vhost.SetRealIP(req.RealIP); err != nil {
return err
}
} else {
if err = vhost.ClearRealIP(); err != nil {
return err
}
}
// 基本认证创建 htpasswd 文件
if len(req.BasicAuth) > 0 {
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "config", "htpasswd")
if err = r.writeBasicAuthUsers(htpasswdPath, req.BasicAuth); err != nil {
return err
}
if err = vhost.SetBasicAuth(map[string]string{"user_file": htpasswdPath}); err != nil {
return err
}
} else {
// 清除基本认证配置和 htpasswd 文件
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "config", "htpasswd")
_ = io.Remove(htpasswdPath)
if err = vhost.ClearBasicAuth(); err != nil {
return err
}
}
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
if err = r.saveCustomConfigs(configDir, req.CustomConfigs); err != nil {
@@ -1097,3 +1155,91 @@ func (r *websiteRepo) reloadWebServer() error {
return nil
}
// readBasicAuthUsers 读取 htpasswd 文件中的用户列表
func (r *websiteRepo) readBasicAuthUsers(siteName string) map[string]string {
htpasswdPath := filepath.Join(app.Root, "sites", siteName, "config", "htpasswd")
if !io.Exists(htpasswdPath) {
return nil
}
file, err := os.Open(htpasswdPath)
if err != nil {
return nil
}
defer func(file *os.File) { _ = file.Close() }(file)
users := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// htpasswd 格式: username:encrypted_password
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
// 返回空密码,前端显示为占位符
users[parts[0]] = ""
}
}
if len(users) == 0 {
return nil
}
return users
}
// writeBasicAuthUsers 将用户凭证写入 htpasswd 文件
func (r *websiteRepo) writeBasicAuthUsers(htpasswdPath string, users map[string]string) error {
// 读取现有用户密码
existingUsers := make(map[string]string)
if io.Exists(htpasswdPath) {
file, err := os.Open(htpasswdPath)
if err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
existingUsers[parts[0]] = parts[1]
}
}
_ = file.Close()
}
}
var lines []string
for username, password := range users {
if username == "" {
continue
}
var hashedPassword string
if password == "" {
// 密码为空,保留现有密码
if existing, ok := existingUsers[username]; ok {
hashedPassword = existing
} else {
// 新用户但没有密码,跳过
continue
}
} else {
// 新密码,使用 bcrypt 加密
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password for user %s: %w", username, err)
}
hashedPassword = string(hash)
}
lines = append(lines, fmt.Sprintf("%s:%s", username, hashedPassword))
}
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return io.Write(htpasswdPath, content, 0600)
}

View File

@@ -67,6 +67,14 @@ type WebsiteUpdate struct {
Upstreams []types.Upstream `json:"upstreams"`
Proxies []types.Proxy `json:"proxies"`
// 重定向
Redirects []types.Redirect `json:"redirects"`
// 高级设置
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
// 自定义配置
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
}

View File

@@ -47,6 +47,14 @@ type WebsiteSetting struct {
Upstreams []types.Upstream `json:"upstreams"`
Proxies []types.Proxy `json:"proxies"`
// 重定向
Redirects []types.Redirect `json:"redirects"`
// 高级设置
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
// 自定义配置
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
}

View File

@@ -78,11 +78,21 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
redirectMatchPattern := regexp.MustCompile(`RedirectMatch\s+(\d+)\s+(\S+)\s+(\S+)`)
if matches := redirectMatchPattern.FindStringSubmatch(contentStr); matches != nil {
statusCode, _ := strconv.Atoi(matches[1])
to := matches[3]
keepURI := strings.Contains(to, "$1")
if keepURI {
to = strings.TrimSuffix(to, "$1")
}
// 还原 from 为简单路径格式
from := matches[2]
from = strings.TrimPrefix(from, "^")
from = strings.TrimSuffix(from, "(.*)$")
from = strings.TrimSuffix(from, "$")
return &types.Redirect{
Type: types.RedirectTypeURL,
From: matches[2],
To: matches[3],
KeepURI: strings.Contains(matches[3], "$1"),
From: from,
To: to,
KeepURI: keepURI,
StatusCode: statusCode,
}, nil
}
@@ -94,11 +104,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
if matches := hostRewritePattern.FindStringSubmatch(contentStr); matches != nil {
statusCode, _ := strconv.Atoi(matches[3])
host := strings.ReplaceAll(matches[1], `\.`, ".")
to := matches[2]
keepURI := strings.Contains(to, "$1")
if keepURI {
to = strings.TrimSuffix(to, "$1")
}
return &types.Redirect{
Type: types.RedirectTypeHost,
From: host,
To: matches[2],
KeepURI: strings.Contains(matches[2], "$1"),
To: to,
KeepURI: keepURI,
StatusCode: statusCode,
}, nil
}

View File

@@ -541,24 +541,25 @@ func (v *baseVhost) RateLimit() *types.RateLimit {
return nil
}
rateLimit := &types.RateLimit{
Zone: make(map[string]string),
}
rateLimit := &types.RateLimit{}
// 获取速率限制值
rateValue := v.vhost.GetDirectiveValue("SetEnv")
if rateValue != "" {
rateLimit.Rate = rateValue
// 获取速率限制值 (SetEnv rate-limit 512)
args := v.vhost.GetDirectiveValues("SetEnv")
if len(args) >= 2 && args[0] == "rate-limit" {
_, _ = fmt.Sscanf(args[1], "%d", &rateLimit.Rate)
}
return rateLimit
}
func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error {
// 设置 mod_ratelimit
v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT")
if limit.Rate != "" {
v.vhost.SetDirective("SetEnv", "rate-limit", limit.Rate)
// Apache mod_ratelimit 只支持流量限制,不支持并发连接限制
if limit.Rate > 0 {
v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT")
v.vhost.SetDirective("SetEnv", "rate-limit", fmt.Sprintf("%d", limit.Rate))
} else {
v.vhost.RemoveDirective("SetOutputFilter")
v.vhost.RemoveDirectives("SetEnv")
}
return nil
@@ -606,6 +607,58 @@ func (v *baseVhost) ClearBasicAuth() error {
return nil
}
func (v *baseVhost) RealIP() *types.RealIP {
// Apache 使用 mod_remoteip
// RemoteIPHeader X-Forwarded-For
// RemoteIPTrustedProxy 127.0.0.1
header := v.vhost.GetDirectiveValue("RemoteIPHeader")
if header == "" {
return nil
}
var from []string
for _, dir := range v.vhost.GetDirectives("RemoteIPTrustedProxy") {
if len(dir.Args) > 0 {
from = append(from, dir.Args[0])
}
}
return &types.RealIP{
From: from,
Header: header,
}
}
func (v *baseVhost) SetRealIP(realIP *types.RealIP) error {
// 清除现有配置
v.vhost.RemoveDirective("RemoteIPHeader")
v.vhost.RemoveDirectives("RemoteIPTrustedProxy")
if realIP == nil || (len(realIP.From) == 0 && realIP.Header == "") {
return nil
}
// 设置 RemoteIPHeader
if realIP.Header != "" {
v.vhost.SetDirective("RemoteIPHeader", realIP.Header)
}
// 设置 RemoteIPTrustedProxy
for _, ip := range realIP.From {
if ip != "" {
v.vhost.AddDirective("RemoteIPTrustedProxy", ip)
}
}
return nil
}
func (v *baseVhost) ClearRealIP() error {
v.vhost.RemoveDirective("RemoteIPHeader")
v.vhost.RemoveDirectives("RemoteIPTrustedProxy")
return nil
}
func (v *baseVhost) Redirects() []types.Redirect {
siteDir := filepath.Join(v.configDir, "site")
redirects, _ := parseRedirectFiles(siteDir)

View File

@@ -237,12 +237,13 @@ func (s *VhostTestSuite) TestRateLimit() {
s.Nil(s.vhost.RateLimit())
limit := &types.RateLimit{
Rate: "512",
Rate: 512,
}
s.NoError(s.vhost.SetRateLimit(limit))
got := s.vhost.RateLimit()
s.NotNil(got)
s.Equal(512, got.Rate)
s.NoError(s.vhost.ClearRateLimit())
s.Nil(s.vhost.RateLimit())

View File

@@ -40,15 +40,19 @@ var order = map[string]int{
"client_max_body_size": 100,
"client_body_buffer_size": 101,
"limit_except": 102,
"limit_req_zone": 103,
"limit_req": 104,
"limit_conn_zone": 105,
"limit_conn": 106,
"allow": 107,
"deny": 108,
"auth_basic": 109,
"auth_basic_user_file": 110,
"limit_rate": 102,
"limit_except": 103,
"limit_req_zone": 104,
"limit_req": 105,
"limit_conn_zone": 106,
"limit_conn": 107,
"allow": 108,
"deny": 109,
"auth_basic": 110,
"auth_basic_user_file": 111,
"set_real_ip_from": 112,
"real_ip_header": 113,
"real_ip_recursive": 114,
"ssl": 200,
"ssl_certificate": 201,

View File

@@ -66,11 +66,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
urlPattern := regexp.MustCompile(`location\s*=\s*(\S+)\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
if matches := urlPattern.FindStringSubmatch(contentStr); matches != nil {
statusCode, _ := strconv.Atoi(matches[2])
to := strings.TrimSpace(matches[3])
keepURI := strings.Contains(to, "$request_uri")
if keepURI {
to = strings.TrimSuffix(to, "$request_uri")
}
return &types.Redirect{
Type: types.RedirectTypeURL,
From: matches[1],
To: strings.TrimSpace(matches[3]),
KeepURI: strings.Contains(matches[3], "$request_uri"),
To: to,
KeepURI: keepURI,
StatusCode: statusCode,
}, nil
}
@@ -79,11 +84,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
hostPattern := regexp.MustCompile(`if\s*\(\s*\$host\s*=\s*"?([^")\s]+)"?\s*\)\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
if matches := hostPattern.FindStringSubmatch(contentStr); matches != nil {
statusCode, _ := strconv.Atoi(matches[2])
to := strings.TrimSpace(matches[3])
keepURI := strings.Contains(to, "$request_uri")
if keepURI {
to = strings.TrimSuffix(to, "$request_uri")
}
return &types.Redirect{
Type: types.RedirectTypeHost,
From: matches[1],
To: strings.TrimSpace(matches[3]),
KeepURI: strings.Contains(matches[3], "$request_uri"),
To: to,
KeepURI: keepURI,
StatusCode: statusCode,
}, nil
}
@@ -92,11 +102,16 @@ func parseRedirectFile(filePath string) (*types.Redirect, error) {
errorPattern := regexp.MustCompile(`error_page\s+404\s*=\s*@redirect_404;[^@]*location\s+@redirect_404\s*\{[^}]*return\s+(\d+)\s+([^;]+);`)
if matches := errorPattern.FindStringSubmatch(contentStr); matches != nil {
statusCode, _ := strconv.Atoi(matches[1])
to := strings.TrimSpace(matches[2])
keepURI := strings.Contains(to, "$request_uri")
if keepURI {
to = strings.TrimSuffix(to, "$request_uri")
}
return &types.Redirect{
Type: types.RedirectType404,
From: "",
To: strings.TrimSpace(matches[2]),
KeepURI: strings.Contains(matches[2], "$request_uri"),
To: to,
KeepURI: keepURI,
StatusCode: statusCode,
}, nil
}

View File

@@ -576,70 +576,84 @@ func (v *baseVhost) ClearSSL() error {
}
func (v *baseVhost) RateLimit() *types.RateLimit {
rate := ""
var perServer, perIP, rate int
// 解析 limit_rate 配置
directive, err := v.parser.FindOne("server.limit_rate")
if err == nil {
if len(v.parser.parameters2Slices(directive.GetParameters())) != 0 {
rate = directive.GetParameters()[0].GetValue()
params := v.parser.parameters2Slices(directive.GetParameters())
if len(params) > 0 {
// 解析 limit_rate 值,如 "512k" -> 512
rateStr := params[0]
rateStr = strings.TrimSuffix(rateStr, "k")
rateStr = strings.TrimSuffix(rateStr, "K")
_, _ = fmt.Sscanf(rateStr, "%d", &rate)
}
}
directives, _ := v.parser.Find("server.limit_conn")
var limitConn [][]string
for _, dir := range directives {
limitConn = append(limitConn, v.parser.parameters2Slices(dir.GetParameters()))
}
if rate == "" && len(limitConn) == 0 {
return nil
}
rateLimit := &types.RateLimit{
Rate: rate,
Zone: make(map[string]string),
}
// 解析 limit_conn 配置
for _, limit := range limitConn {
if len(limit) >= 2 {
// limit_conn zone connections
// 例如: limit_conn perip 10
rateLimit.Zone[limit[0]] = limit[1]
directives, _ := v.parser.Find("server.limit_conn")
for _, dir := range directives {
params := v.parser.parameters2Slices(dir.GetParameters())
if len(params) >= 2 {
var val int
_, _ = fmt.Sscanf(params[1], "%d", &val)
switch params[0] {
case "perserver":
perServer = val
case "perip":
perIP = val
}
}
}
return rateLimit
if perServer == 0 && perIP == 0 && rate == 0 {
return nil
}
return &types.RateLimit{
PerServer: perServer,
PerIP: perIP,
Rate: rate,
}
}
func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error {
var limitConns [][]string
for zone, connections := range limit.Zone {
limitConns = append(limitConns, []string{zone, connections})
}
// 设置限速
// 设置限速 limit_rate
_ = v.parser.Clear("server.limit_rate")
if err := v.parser.Set("server", []*config.Directive{
{
Name: "limit_rate",
Parameters: []config.Parameter{{Value: limit.Rate}},
},
}); err != nil {
return err
}
// 设置并发连接数限制
_ = v.parser.Clear("server.limit_conn")
var directives []*config.Directive
for _, lim := range limitConns {
if len(lim) >= 2 {
directives = append(directives, &config.Directive{
Name: "limit_conn",
Parameters: v.parser.slices2Parameters(lim),
})
if limit.Rate > 0 {
if err := v.parser.Set("server", []*config.Directive{
{
Name: "limit_rate",
Parameters: []config.Parameter{{Value: fmt.Sprintf("%dk", limit.Rate)}},
},
}); err != nil {
return err
}
}
return v.parser.Set("server", directives)
// 设置并发连接数限制 limit_conn
_ = v.parser.Clear("server.limit_conn")
var directives []*config.Directive
if limit.PerServer > 0 {
directives = append(directives, &config.Directive{
Name: "limit_conn",
Parameters: []config.Parameter{{Value: "perserver"}, {Value: fmt.Sprintf("%d", limit.PerServer)}},
})
}
if limit.PerIP > 0 {
directives = append(directives, &config.Directive{
Name: "limit_conn",
Parameters: []config.Parameter{{Value: "perip"}, {Value: fmt.Sprintf("%d", limit.PerIP)}},
})
}
if len(directives) > 0 {
if err := v.parser.Set("server", directives); err != nil {
return err
}
}
return nil
}
func (v *baseVhost) ClearRateLimit() error {
@@ -706,6 +720,100 @@ func (v *baseVhost) ClearBasicAuth() error {
return nil
}
func (v *baseVhost) RealIP() *types.RealIP {
// 解析 set_real_ip_from 配置
var from []string
directives, _ := v.parser.Find("server.set_real_ip_from")
for _, dir := range directives {
params := v.parser.parameters2Slices(dir.GetParameters())
if len(params) > 0 {
from = append(from, params[0])
}
}
// 解析 real_ip_header 配置
header := ""
directive, err := v.parser.FindOne("server.real_ip_header")
if err == nil {
params := v.parser.parameters2Slices(directive.GetParameters())
if len(params) > 0 {
header = params[0]
}
}
// 解析 real_ip_recursive 配置
recursive := false
recursiveDir, err := v.parser.FindOne("server.real_ip_recursive")
if err == nil {
params := v.parser.parameters2Slices(recursiveDir.GetParameters())
if len(params) > 0 && params[0] == "on" {
recursive = true
}
}
if len(from) == 0 && header == "" {
return nil
}
return &types.RealIP{
From: from,
Header: header,
Recursive: recursive,
}
}
func (v *baseVhost) SetRealIP(realIP *types.RealIP) error {
// 清除现有配置
_ = v.parser.Clear("server.set_real_ip_from")
_ = v.parser.Clear("server.real_ip_header")
_ = v.parser.Clear("server.real_ip_recursive")
if realIP == nil || (len(realIP.From) == 0 && realIP.Header == "") {
return nil
}
var directives []*config.Directive
// 添加 set_real_ip_from 配置
for _, ip := range realIP.From {
if ip != "" {
directives = append(directives, &config.Directive{
Name: "set_real_ip_from",
Parameters: []config.Parameter{{Value: ip}},
})
}
}
// 添加 real_ip_header 配置
if realIP.Header != "" {
directives = append(directives, &config.Directive{
Name: "real_ip_header",
Parameters: []config.Parameter{{Value: realIP.Header}},
})
}
// 添加 real_ip_recursive 配置
if realIP.Recursive {
directives = append(directives, &config.Directive{
Name: "real_ip_recursive",
Parameters: []config.Parameter{{Value: "on"}},
})
}
if len(directives) > 0 {
return v.parser.Set("server", directives)
}
return nil
}
func (v *baseVhost) ClearRealIP() error {
_ = v.parser.Clear("server.set_real_ip_from")
_ = v.parser.Clear("server.real_ip_header")
_ = v.parser.Clear("server.real_ip_recursive")
return nil
}
func (v *baseVhost) Redirects() []types.Redirect {
siteDir := filepath.Join(v.configDir, "site")
redirects, _ := parseRedirectFiles(siteDir)

View File

@@ -249,16 +249,17 @@ func (s *VhostTestSuite) TestRateLimit() {
s.Nil(s.vhost.RateLimit())
limit := &types.RateLimit{
Rate: "512k",
Zone: map[string]string{
"perip": "10",
},
PerServer: 300,
PerIP: 25,
Rate: 512,
}
s.NoError(s.vhost.SetRateLimit(limit))
got := s.vhost.RateLimit()
s.NotNil(got)
s.Equal("512k", got.Rate)
s.Equal(300, got.PerServer)
s.Equal(25, got.PerIP)
s.Equal(512, got.Rate)
s.NoError(s.vhost.ClearRateLimit())
s.Nil(s.vhost.RateLimit())

View File

@@ -76,6 +76,13 @@ type Vhost interface {
// ClearBasicAuth 清除基本认证
ClearBasicAuth() error
// RealIP 取真实 IP 配置
RealIP() *RealIP
// SetRealIP 设置真实 IP 配置
SetRealIP(realIP *RealIP) error
// ClearRealIP 清除真实 IP 配置
ClearRealIP() error
// Config 取指定名称的配置内容
// type 可选值: "site", "shared"
Config(name string, typ string) string
@@ -162,9 +169,16 @@ type SSLConfig struct {
// RateLimit 限流限速配置
type RateLimit struct {
Rate string `json:"rate"` // 速率限制,如: "512k", "10r/s"
Concurrent int `json:"concurrent"` // 并发连接数限制
Zone map[string]string `json:"zone"` // 条件配置,如: map["perip"] = "10"
PerServer int `json:"per_server"` // 站点最大并发数 (limit_conn perserver X)
PerIP int `json:"per_ip"` // 单 IP 最大并发数 (limit_conn perip X)
Rate int `json:"rate"` // 流量限制,单位 KB (limit_rate Xk)
}
// RealIP 真实 IP 配置
type RealIP struct {
From []string `json:"from"` // 可信 IP 来源列表 (set_real_ip_from)
Header string `json:"header"` // 真实 IP 头 (real_ip_header),如: X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive"` // 递归搜索 (real_ip_recursive)
}
// IncludeFile 包含文件配置

View File

@@ -48,6 +48,10 @@ const { data: setting, send: fetchSetting } = useRequest(website.config(Number(i
open_basedir: false,
upstreams: [],
proxies: [],
redirects: [],
rate_limit: null,
real_ip: null,
basic_auth: {},
custom_configs: []
}
})
@@ -385,6 +389,108 @@ const updateTimeoutUnit = (proxy: any, unit: string) => {
proxy.resolver_timeout = buildDuration(parsed.value, unit)
}
// ========== 重定向相关 ==========
// 重定向类型选项
const redirectTypeOptions = [
{ label: $gettext('URL Redirect'), value: 'url' },
{ label: $gettext('Host Redirect'), value: 'host' },
{ label: $gettext('404 Redirect'), value: '404' }
]
// 状态码选项
const redirectStatusCodeOptions = [
{ label: '301 - ' + $gettext('Moved Permanently'), value: 301 },
{ label: '302 - ' + $gettext('Found'), value: 302 },
{ label: '307 - ' + $gettext('Temporary Redirect'), value: 307 },
{ label: '308 - ' + $gettext('Permanent Redirect'), value: 308 }
]
// 添加重定向规则
const addRedirect = () => {
if (!setting.value.redirects) {
setting.value.redirects = []
}
setting.value.redirects.push({
type: 'url',
from: '/',
to: '/new',
keep_uri: true,
status_code: 308
})
}
// 删除重定向规则
const removeRedirect = (index: number) => {
if (setting.value.redirects) {
setting.value.redirects.splice(index, 1)
}
}
// 获取重定向类型的标签
const getRedirectTypeLabel = (type: string) => {
const option = redirectTypeOptions.find((opt) => opt.value === type)
return option ? option.label : type
}
// ========== 高级设置相关(限流限速、真实 IP、基本认证==========
// 限流限速是否启用
const rateLimitEnabled = computed({
get: () => setting.value.rate_limit !== null,
set: (value: boolean) => {
if (value) {
setting.value.rate_limit = {
per_server: 0,
per_ip: 0,
rate: 0
}
} else {
setting.value.rate_limit = null
}
}
})
// 真实 IP 是否启用
const realIPEnabled = computed({
get: () => setting.value.real_ip !== null,
set: (value: boolean) => {
if (value) {
setting.value.real_ip = {
from: [],
header: 'X-Real-IP',
recursive: false
}
} else {
setting.value.real_ip = null
}
}
})
// 真实 IP Header 选项
const realIPHeaderOptions = [
{ label: 'X-Real-IP', value: 'X-Real-IP' },
{ label: 'X-Forwarded-For', value: 'X-Forwarded-For' },
{ label: 'CF-Connecting-IP', value: 'CF-Connecting-IP' },
{ label: 'True-Client-IP', value: 'True-Client-IP' },
{ label: 'Ali-Cdn-Real-Ip', value: 'Ali-Cdn-Real-Ip' },
{ label: 'EO-Connecting-IP', value: 'EO-Connecting-IP' }
]
// 添加基本认证用户
const addBasicAuthUser = () => {
if (!setting.value.basic_auth) {
setting.value.basic_auth = {}
}
const index = Object.keys(setting.value.basic_auth).length + 1
setting.value.basic_auth[`user${index}`] = ''
}
// 删除基本认证用户
const removeBasicAuthUser = (username: string) => {
if (setting.value.basic_auth) {
delete setting.value.basic_auth[username]
}
}
// ========== 自定义配置相关 ==========
// 作用域选项
const scopeOptions = [
@@ -505,7 +611,7 @@ const removeCustomConfig = (index: number) => {
ghost-class="ghost-card"
>
<template #item="{ element: upstream, index }">
<n-card closable @close="removeUpstream(index)" style="margin-bottom: 16px">
<n-card closable @close="removeUpstream(index)" mb-16>
<template #header>
<n-flex align="center" :size="8">
<!-- 拖拽手柄 -->
@@ -548,7 +654,7 @@ const removeCustomConfig = (index: number) => {
v-model:value="upstream.keepalive"
:min="0"
:max="1000"
style="width: 100%"
w-full
/>
</n-form-item-gi>
<n-form-item-gi v-if="isNginx" :span="12" :label="$gettext('DNS Resolver')">
@@ -567,7 +673,7 @@ const removeCustomConfig = (index: number) => {
:value="parseDuration(upstream.resolver_timeout).value"
:min="1"
:max="3600"
style="flex: 1"
flex-1
@update:value="
(v: number | null) => updateUpstreamTimeoutValue(upstream, v ?? 5)
"
@@ -582,7 +688,7 @@ const removeCustomConfig = (index: number) => {
</n-form-item-gi>
</n-grid>
<n-form-item :label="$gettext('Backend Servers')">
<n-flex vertical :size="8" style="width: 100%">
<n-flex vertical :size="8" w-full>
<n-flex
v-for="(options, address) in upstream.servers"
:key="String(address)"
@@ -592,7 +698,7 @@ const removeCustomConfig = (index: number) => {
<n-input
:default-value="String(address)"
:placeholder="$gettext('Server address, e.g., 127.0.0.1:8080')"
style="flex: 1"
flex-1
@change="
(newAddr: string) => {
const oldAddr = String(address)
@@ -606,14 +712,14 @@ const removeCustomConfig = (index: number) => {
<n-input
:value="String(options)"
:placeholder="$gettext('Options, e.g., weight=5 backup')"
style="flex: 1"
flex-1
@update:value="(v: string) => (upstream.servers[String(address)] = v)"
/>
<n-button
type="error"
secondary
size="small"
style="flex-shrink: 0"
flex-shrink-0
@click="delete upstream.servers[String(address)]"
>
{{ $gettext('Remove') }}
@@ -651,7 +757,7 @@ const removeCustomConfig = (index: number) => {
ghost-class="ghost-card"
>
<template #item="{ element: proxy, index }">
<n-card closable @close="removeProxy(index)" style="margin-bottom: 16px">
<n-card closable @close="removeProxy(index)" mb-16>
<template #header>
<n-flex align="center" :size="8">
<!-- 拖拽手柄 -->
@@ -727,7 +833,7 @@ const removeCustomConfig = (index: number) => {
:value="parseDuration(proxy.resolver_timeout).value"
:min="1"
:max="3600"
style="flex: 1"
flex-1
@update:value="(v: number | null) => updateTimeoutValue(proxy, v ?? 5)"
/>
<n-select
@@ -750,7 +856,7 @@ const removeCustomConfig = (index: number) => {
<n-input
:value="String(fromValue)"
:placeholder="$gettext('Original content')"
style="flex: 1"
flex-1
@blur="
(e: FocusEvent) => {
const newFrom = (e.target as HTMLInputElement).value
@@ -762,18 +868,18 @@ const removeCustomConfig = (index: number) => {
}
"
/>
<span style="flex-shrink: 0">=></span>
<span flex-shrink-0>=></span>
<n-input
:value="String(toValue)"
:placeholder="$gettext('Replacement content')"
style="flex: 1"
flex-1
@update:value="(v: string) => (proxy.replaces[String(fromValue)] = v)"
/>
<n-button
type="error"
secondary
size="small"
style="flex-shrink: 0"
flex-shrink-0
@click="delete proxy.replaces[String(fromValue)]"
>
{{ $gettext('Remove') }}
@@ -925,6 +1031,246 @@ const removeCustomConfig = (index: number) => {
<common-editor v-if="setting" v-model:value="setting.rewrite" height="60vh" />
</n-flex>
</n-tab-pane>
<n-tab-pane name="redirects" :tab="$gettext('Redirects')">
<n-flex vertical>
<!-- 重定向卡片列表 -->
<draggable
v-model="setting.redirects"
item-key="from"
handle=".drag-handle"
:animation="200"
ghost-class="ghost-card"
>
<template #item="{ element: redirect, index }">
<n-card closable @close="removeRedirect(index)" mb-16>
<template #header>
<n-flex align="center" :size="8">
<!-- 拖拽手柄 -->
<div class="drag-handle" cursor-grab>
<the-icon icon="mdi:drag" :size="20" />
</div>
<span>{{ $gettext('Rule') }} #{{ index + 1 }}</span>
<n-tag size="small" :type="redirect.type === '404' ? 'warning' : 'default'">
{{ getRedirectTypeLabel(redirect.type) }}
</n-tag>
<template v-if="redirect.type !== '404'">
<n-tag size="small">{{ redirect.from }}</n-tag>
<the-icon icon="mdi:arrow-right-bold" :size="20" />
</template>
<n-tag size="small" type="success">{{ redirect.to }}</n-tag>
</n-flex>
</template>
<n-form label-placement="left" label-width="140px">
<n-grid :cols="24" :x-gap="16">
<n-form-item-gi :span="12" :label="$gettext('Redirect Type')">
<n-select v-model:value="redirect.type" :options="redirectTypeOptions" />
</n-form-item-gi>
<n-form-item-gi :span="12" :label="$gettext('Status Code')">
<n-select
v-model:value="redirect.status_code"
:options="redirectStatusCodeOptions"
/>
</n-form-item-gi>
<n-form-item-gi
v-if="redirect.type !== '404'"
:span="12"
:label="$gettext('Source')"
>
<n-input
v-model:value="redirect.from"
:placeholder="
redirect.type === 'url'
? $gettext('Source path, e.g., /old')
: $gettext('Source host, e.g., example.com')
"
/>
</n-form-item-gi>
<n-form-item-gi
:span="redirect.type === '404' ? 24 : 12"
:label="$gettext('Target')"
>
<n-input
v-model:value="redirect.to"
:placeholder="
redirect.type === 'url'
? $gettext('Target path, e.g., /new')
: $gettext('Target URL, e.g., https://example.com')
"
/>
</n-form-item-gi>
<n-form-item-gi :span="12" :label="$gettext('Keep URI')">
<n-switch v-model:value="redirect.keep_uri" />
<n-text depth="3" class="ml-8">
{{ $gettext('Keep the original request path and query parameters') }}
</n-text>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
</template>
</draggable>
<!-- 空状态 -->
<n-empty v-if="!setting.redirects || setting.redirects.length === 0">
{{ $gettext('No redirect rules configured') }}
</n-empty>
<!-- 添加按钮 -->
<n-button type="primary" dashed @click="addRedirect" mb-20>
{{ $gettext('Add Redirect Rule') }}
</n-button>
</n-flex>
</n-tab-pane>
<n-tab-pane name="advanced" :tab="$gettext('Advanced Settings')">
<n-flex vertical>
<!-- 限流限速设置 -->
<n-card :title="$gettext('Rate Limiting')" mb-16>
<n-form label-placement="left" label-width="140px">
<n-form-item :label="$gettext('Enable Rate Limiting')">
<n-switch v-model:value="rateLimitEnabled" />
</n-form-item>
<template v-if="rateLimitEnabled && setting.rate_limit">
<n-form-item :label="$gettext('Concurrent Limit')">
<n-input-number
v-model:value="setting.rate_limit.per_server"
:min="0"
:max="100000"
w-full
/>
<template #feedback>
{{ $gettext('Limit the maximum concurrent connections for this site') }}
</template>
</n-form-item>
<n-form-item :label="$gettext('Per IP Limit')">
<n-input-number
v-model:value="setting.rate_limit.per_ip"
:min="0"
:max="10000"
w-full
/>
<template #feedback>
{{ $gettext('Limit the maximum concurrent connections per IP') }}
</template>
</n-form-item>
<n-form-item :label="$gettext('Rate Limit')">
<n-input-number
v-model:value="setting.rate_limit.rate"
:min="0"
:max="1000000"
w-full
/>
<template #feedback>
{{ $gettext('Limit the rate of each request (unit: KB)') }}
</template>
</n-form-item>
</template>
</n-form>
</n-card>
<!-- 真实 IP 设置 -->
<n-card :title="$gettext('Real IP')" mb-16>
<n-alert type="info" mb-16>
{{
$gettext(
'Configure trusted proxy IPs (e.g., CDN or Frp) to identify real visitor IPs.'
)
}}
</n-alert>
<n-alert type="warning" mb-16>
{{
$gettext(
'If using Frp, fill in the Frp IP address (e.g., 127.0.0.1). If using CDN, fill in the CDN IP ranges. If unsure, you can fill in 0.0.0.0/0 (ipv4) or ::/0 (ipv6) [insecure].'
)
}}
</n-alert>
<n-form label-placement="left" label-width="140px">
<n-form-item :label="$gettext('Enable')">
<n-switch v-model:value="realIPEnabled" />
</n-form-item>
<template v-if="realIPEnabled && setting.real_ip">
<n-form-item :label="$gettext('IP Sources')">
<n-dynamic-input
v-model:value="setting.real_ip.from"
:placeholder="$gettext('e.g., 127.0.0.1 or 10.0.0.0/8')"
/>
</n-form-item>
<n-form-item :label="$gettext('IP Header')">
<n-select v-model:value="setting.real_ip.header" :options="realIPHeaderOptions" />
</n-form-item>
<n-form-item :label="$gettext('Recursive')">
<n-switch v-model:value="setting.real_ip.recursive" />
<template #feedback>
{{ $gettext('Recursively search for real IP in X-Forwarded-For header') }}
</template>
</n-form-item>
</template>
</n-form>
</n-card>
<!-- 基本认证设置 -->
<n-card :title="$gettext('Basic Authentication')" mb-16>
<n-form label-placement="left" label-width="140px">
<n-form-item :label="$gettext('User Credentials')">
<n-flex vertical :size="8" w-full>
<n-flex
v-for="(password, username) in setting.basic_auth"
:key="String(username)"
:size="8"
align="center"
>
<n-input
:default-value="String(username)"
:placeholder="$gettext('Username')"
flex-1
@change="
(newUsername: string) => {
const oldUsername = String(username)
if (newUsername && newUsername !== oldUsername) {
// 检查新用户名是否已存在
if (setting.basic_auth[newUsername] !== undefined) {
window.$message.error($gettext('Username already exists'))
return
}
setting.basic_auth[newUsername] = setting.basic_auth[oldUsername]
delete setting.basic_auth[oldUsername]
}
}
"
/>
<n-input
:value="String(password)"
type="password"
show-password-on="click"
:placeholder="$gettext('Password')"
flex-1
@update:value="(v: string) => (setting.basic_auth[String(username)] = v)"
/>
<n-button
type="error"
secondary
size="small"
flex-shrink-0
@click="removeBasicAuthUser(String(username))"
>
{{ $gettext('Remove') }}
</n-button>
</n-flex>
<n-button dashed size="small" @click="addBasicAuthUser">
{{ $gettext('Add User') }}
</n-button>
</n-flex>
</n-form-item>
</n-form>
<n-alert v-if="Object.keys(setting.basic_auth || {}).length > 0" type="info">
{{
$gettext(
'Visitors will need to enter a username and password to access this website.'
)
}}
</n-alert>
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="custom_configs" :tab="$gettext('Custom Configs')">
<n-flex vertical>
<!-- 自定义配置列表 -->
@@ -936,7 +1282,7 @@ const removeCustomConfig = (index: number) => {
ghost-class="ghost-card"
>
<template #item="{ element: config, index }">
<n-card closable @close="removeCustomConfig(index)" style="margin-bottom: 16px">
<n-card closable @close="removeCustomConfig(index)" mb-16>
<template #header>
<n-flex align="center" :size="8">
<!-- 拖拽手柄 -->