From a386dc3a2a9d027c54d47c1483eb83a62a72a718 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:32:13 +0800 Subject: [PATCH] Add redirect and advanced settings tabs to website editor (#1267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: 耗子 --- go.sum | 7 - internal/data/website.go | 146 +++++++++++ internal/http/request/website.go | 8 + pkg/types/website.go | 8 + pkg/webserver/apache/redirect.go | 25 +- pkg/webserver/apache/vhost.go | 75 +++++- pkg/webserver/apache/vhost_test.go | 3 +- pkg/webserver/nginx/data.go | 22 +- pkg/webserver/nginx/redirect.go | 27 ++- pkg/webserver/nginx/vhost.go | 204 ++++++++++++---- pkg/webserver/nginx/vhost_test.go | 11 +- pkg/webserver/types/vhost.go | 20 +- web/src/views/website/EditView.vue | 374 +++++++++++++++++++++++++++-- 13 files changed, 821 insertions(+), 109 deletions(-) diff --git a/go.sum b/go.sum index a2951e15..c7e7f561 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/data/website.go b/internal/data/website.go index 97d72092..d2d8fd69 100644 --- a/internal/data/website.go +++ b/internal/data/website.go @@ -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) +} diff --git a/internal/http/request/website.go b/internal/http/request/website.go index 292541d5..3fcd8dfe 100644 --- a/internal/http/request/website.go +++ b/internal/http/request/website.go @@ -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"` } diff --git a/pkg/types/website.go b/pkg/types/website.go index c7cad43e..b38461c6 100644 --- a/pkg/types/website.go +++ b/pkg/types/website.go @@ -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"` } diff --git a/pkg/webserver/apache/redirect.go b/pkg/webserver/apache/redirect.go index 362a738d..ccb3e7b3 100644 --- a/pkg/webserver/apache/redirect.go +++ b/pkg/webserver/apache/redirect.go @@ -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 } diff --git a/pkg/webserver/apache/vhost.go b/pkg/webserver/apache/vhost.go index 8caec770..851ae627 100644 --- a/pkg/webserver/apache/vhost.go +++ b/pkg/webserver/apache/vhost.go @@ -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) diff --git a/pkg/webserver/apache/vhost_test.go b/pkg/webserver/apache/vhost_test.go index 069c2d46..a14fd852 100644 --- a/pkg/webserver/apache/vhost_test.go +++ b/pkg/webserver/apache/vhost_test.go @@ -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()) diff --git a/pkg/webserver/nginx/data.go b/pkg/webserver/nginx/data.go index f602b493..f53c554b 100644 --- a/pkg/webserver/nginx/data.go +++ b/pkg/webserver/nginx/data.go @@ -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, diff --git a/pkg/webserver/nginx/redirect.go b/pkg/webserver/nginx/redirect.go index e1488af2..ce36bead 100644 --- a/pkg/webserver/nginx/redirect.go +++ b/pkg/webserver/nginx/redirect.go @@ -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 } diff --git a/pkg/webserver/nginx/vhost.go b/pkg/webserver/nginx/vhost.go index 0fe9306c..08140526 100644 --- a/pkg/webserver/nginx/vhost.go +++ b/pkg/webserver/nginx/vhost.go @@ -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) diff --git a/pkg/webserver/nginx/vhost_test.go b/pkg/webserver/nginx/vhost_test.go index 6e8741fa..9796bb3f 100644 --- a/pkg/webserver/nginx/vhost_test.go +++ b/pkg/webserver/nginx/vhost_test.go @@ -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()) diff --git a/pkg/webserver/types/vhost.go b/pkg/webserver/types/vhost.go index 381645d5..d01046aa 100644 --- a/pkg/webserver/types/vhost.go +++ b/pkg/webserver/types/vhost.go @@ -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 包含文件配置 diff --git a/web/src/views/website/EditView.vue b/web/src/views/website/EditView.vue index ff8193c1..102eef7c 100644 --- a/web/src/views/website/EditView.vue +++ b/web/src/views/website/EditView.vue @@ -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" >