From b341eddd04abbf5c777c6eaa2934c84c09008e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Tue, 13 Jan 2026 02:55:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81apache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ace/wire_gen.go | 4 +- cmd/cli/wire_gen.go | 2 +- internal/data/cert.go | 49 ++++++--- pkg/acme/client.go | 18 ++-- pkg/acme/solvers.go | 168 +++++++++++++++++++++++++++-- pkg/webserver/apache/proxy.go | 74 ++++++------- pkg/webserver/apache/vhost.go | 4 +- pkg/webserver/apache/vhost_test.go | 116 +++++++++++++++++++- pkg/webserver/nginx/proxy.go | 3 - web/src/views/website/EditView.vue | 22 ++-- 10 files changed, 373 insertions(+), 87 deletions(-) diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index 2c2957b23..da01aee94 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -74,9 +74,9 @@ func initWeb() (*app.Web, error) { databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger) databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo) databaseRepo := data.NewDatabaseRepo(locale, db, logger, databaseServerRepo, databaseUserRepo) - certRepo := data.NewCertRepo(locale, db, logger) - certAccountRepo := data.NewCertAccountRepo(locale, db, userRepo, logger) settingRepo := data.NewSettingRepo(locale, db, logger, config, taskRepo) + certRepo := data.NewCertRepo(locale, db, logger, settingRepo) + certAccountRepo := data.NewCertAccountRepo(locale, db, userRepo, logger) websiteRepo := data.NewWebsiteRepo(locale, db, logger, cacheRepo, databaseRepo, databaseServerRepo, databaseUserRepo, certRepo, certAccountRepo, settingRepo) environmentRepo := data.NewEnvironmentRepo(locale, config, cacheRepo, taskRepo) cronRepo := data.NewCronRepo(locale, db, logger) diff --git a/cmd/cli/wire_gen.go b/cmd/cli/wire_gen.go index 9bbf8ebc0..15d514876 100644 --- a/cmd/cli/wire_gen.go +++ b/cmd/cli/wire_gen.go @@ -65,7 +65,7 @@ func initCli() (*app.Cli, error) { databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger) databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo) databaseRepo := data.NewDatabaseRepo(locale, db, logger, databaseServerRepo, databaseUserRepo) - certRepo := data.NewCertRepo(locale, db, logger) + certRepo := data.NewCertRepo(locale, db, logger, settingRepo) certAccountRepo := data.NewCertAccountRepo(locale, db, userRepo, logger) websiteRepo := data.NewWebsiteRepo(locale, db, logger, cacheRepo, databaseRepo, databaseServerRepo, databaseUserRepo, certRepo, certAccountRepo, settingRepo) backupRepo := data.NewBackupRepo(locale, config, db, logger, settingRepo, websiteRepo) diff --git a/internal/data/cert.go b/internal/data/cert.go index c9f043031..1f69a81ec 100644 --- a/internal/data/cert.go +++ b/internal/data/cert.go @@ -27,17 +27,19 @@ import ( ) type certRepo struct { - t *gotext.Locale - db *gorm.DB - log *slog.Logger - client *acme.Client + t *gotext.Locale + db *gorm.DB + log *slog.Logger + settingRepo biz.SettingRepo + client *acme.Client } -func NewCertRepo(t *gotext.Locale, db *gorm.DB, log *slog.Logger) biz.CertRepo { +func NewCertRepo(t *gotext.Locale, db *gorm.DB, log *slog.Logger, settingRepo biz.SettingRepo) biz.CertRepo { return &certRepo{ - t: t, - db: db, - log: log, + t: t, + db: db, + log: log, + settingRepo: settingRepo, } } @@ -185,6 +187,8 @@ func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) { return nil, err } + webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver) + if cert.DNS != nil { client.UseDns(cert.DNS.Type, cert.DNS.Data) } else { @@ -197,7 +201,7 @@ func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) { } } conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name) - client.UseHTTP(conf) + client.UseHTTP(conf, webServer) } } @@ -264,7 +268,13 @@ func (r *certRepo) ObtainPanel(account *biz.CertAccount, ips []string) ([]byte, if err != nil { return nil, nil, err } - client.UsePanel(ips, filepath.Join(app.Root, "server/nginx/conf/acme.conf")) + + webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver) + confPath := filepath.Join(app.Root, "server/nginx/conf/acme.conf") + if webServer == "apache" { + confPath = filepath.Join(app.Root, "server/apache/conf/extra/acme.conf") + } + client.UsePanel(ips, confPath, webServer) ssl, err := client.ObtainIPCertificate(context.Background(), ips, acme.KeyEC256) if err != nil { @@ -317,6 +327,8 @@ func (r *certRepo) Renew(id uint) (*acme.Certificate, error) { return nil, errors.New(r.t.Get("this certificate has not been obtained successfully and cannot be renewed")) } + webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver) + if cert.DNS != nil { client.UseDns(cert.DNS.Type, cert.DNS.Data) } else { @@ -329,7 +341,7 @@ func (r *certRepo) Renew(id uint) (*acme.Certificate, error) { } } conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name) - client.UseHTTP(conf) + client.UseHTTP(conf, webServer) } } @@ -431,9 +443,18 @@ func (r *certRepo) Deploy(ID, WebsiteID uint) error { if err = io.Write(fmt.Sprintf("%s/sites/%s/config/private.key", app.Root, website.Name), cert.Key, 0600); err != nil { return err } - if err = systemctl.Reload("nginx"); err != nil { - _, err = shell.Execf("nginx -t") - return err + + webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver) + if webServer == "apache" { + if err = systemctl.Reload("apache"); err != nil { + _, err = shell.Execf("apachectl -t") + return err + } + } else { + if err = systemctl.Reload("nginx"); err != nil { + _, err = shell.Execf("nginx -t") + return err + } } return nil diff --git a/pkg/acme/client.go b/pkg/acme/client.go index 01159bc3b..dcae226eb 100644 --- a/pkg/acme/client.go +++ b/pkg/acme/client.go @@ -52,23 +52,27 @@ func (c *Client) UseManualDns(check ...bool) { } // UseHTTP 使用 HTTP 验证 -// conf nginx 配置文件路径 -func (c *Client) UseHTTP(conf string) { +// conf 配置文件路径 +// webServer web 服务器类型 ("nginx" 或 "apache") +func (c *Client) UseHTTP(conf string, webServer string) { c.zClient.ChallengeSolvers = map[string]acmez.Solver{ acme.ChallengeTypeHTTP01: httpSolver{ - conf: conf, + conf: conf, + webServer: webServer, }, } } // UsePanel 使用面板 HTTP 验证 // ip 外网访问 IP 地址 -// conf nginx 配置文件路径 -func (c *Client) UsePanel(ip []string, conf string) { +// conf 配置文件路径 +// webServer web 服务器类型 ("nginx" 或 "apache") +func (c *Client) UsePanel(ip []string, conf string, webServer string) { c.zClient.ChallengeSolvers = map[string]acmez.Solver{ acme.ChallengeTypeHTTP01: &panelSolver{ - ip: ip, - conf: conf, + ip: ip, + conf: conf, + webServer: webServer, }, } } diff --git a/pkg/acme/solvers.go b/pkg/acme/solvers.go index 19e8246c1..3b06634aa 100644 --- a/pkg/acme/solvers.go +++ b/pkg/acme/solvers.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -31,9 +32,10 @@ import ( var panelSolverGlobal sync.Mutex type panelSolver struct { - ip []string - conf string - server *http.Server + ip []string + conf string + webServer string // "nginx" or "apache" + server *http.Server // tokens 存储所有待验证的 challenge,key 为路径,value 为 token tokens map[string]string // presentCount Present 调用计数 @@ -70,8 +72,11 @@ func (s *panelSolver) Present(_ context.Context, challenge acme.Challenge) error return s.startServer() } - // 否则使用 nginx 配置 + // 否则使用 web 服务器配置 s.useBuiltin = false + if s.webServer == "apache" { + return s.writeApacheConfig() + } return s.writeNginxConfig() } @@ -127,6 +132,48 @@ func (s *panelSolver) writeNginxConfig() error { return nil } +func (s *panelSolver) writeApacheConfig() error { + // Apache 使用 Alias 指向一个临时目录,将 token 写入文件 + tokenDir := "/tmp/acme-challenge" + if err := os.MkdirAll(tokenDir, 0755); err != nil { + return fmt.Errorf("failed to create token directory: %w", err) + } + + // 写入 token 文件 + for path, token := range s.tokens { + // path 格式为 /.well-known/acme-challenge/xxx + tokenFile := filepath.Join(tokenDir, filepath.Base(path)) + if err := os.WriteFile(tokenFile, []byte(token), 0644); err != nil { + return fmt.Errorf("failed to write token file: %w", err) + } + } + + var conf strings.Builder + conf.WriteString(fmt.Sprintf("\n ServerName %s\n", s.ip[0])) + if len(s.ip) > 1 { + for _, ip := range s.ip[1:] { + conf.WriteString(fmt.Sprintf(" ServerAlias %s\n", ip)) + } + } + conf.WriteString(fmt.Sprintf(" Alias /.well-known/acme-challenge %s\n", tokenDir)) + conf.WriteString(fmt.Sprintf(" \n", tokenDir)) + conf.WriteString(" Require all granted\n") + conf.WriteString(" ForceType text/plain\n") + conf.WriteString(" \n") + conf.WriteString("\n") + + if err := os.WriteFile(s.conf, []byte(conf.String()), 0600); err != nil { + return fmt.Errorf("failed to write apache config %q: %w", s.conf, err) + } + + if err := systemctl.Reload("apache"); err != nil { + _, err = shell.Execf("apachectl -t") + return fmt.Errorf("failed to reload apache: %w", err) + } + + return nil +} + // CleanUp cleans up the HTTP server on last call. func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error { s.cleanupCount++ @@ -148,9 +195,19 @@ func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error { return nil } - // 清理 nginx 配置 + // 清理配置文件 if err := os.WriteFile(s.conf, []byte(""), 0600); err != nil { - return fmt.Errorf("failed to write to nginx config %q: %w", s.conf, err) + return fmt.Errorf("failed to write to config %q: %w", s.conf, err) + } + + // 清理 Apache token 目录 + if s.webServer == "apache" { + _ = os.RemoveAll("/tmp/acme-challenge") + if err := systemctl.Reload("apache"); err != nil { + _, _ = shell.Execf("apachectl -t") + return fmt.Errorf("failed to reload apache: %w", err) + } + return nil } if err := systemctl.Reload("nginx"); err != nil { @@ -162,15 +219,26 @@ func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error { } type httpSolver struct { - conf string + conf string + webServer string // "nginx" or "apache" } func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error { + path := challenge.HTTP01ResourcePath() + token := challenge.KeyAuthorization + + if s.webServer == "apache" { + return s.presentApache(path, token) + } + return s.presentNginx(path, token) +} + +func (s httpSolver) presentNginx(path, token string) error { conf := fmt.Sprintf(`location = %s { default_type text/plain; return 200 %q; } -`, challenge.HTTP01ResourcePath(), challenge.KeyAuthorization) +`, path, token) file, err := os.OpenFile(s.conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { @@ -190,8 +258,57 @@ func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error { return nil } +func (s httpSolver) presentApache(path, token string) error { + // 创建 token 目录 + tokenDir := filepath.Dir(s.conf) + "/acme-challenge" + if err := os.MkdirAll(tokenDir, 0755); err != nil { + return fmt.Errorf("failed to create token directory: %w", err) + } + + // 写入 token 文件 + tokenFile := filepath.Join(tokenDir, filepath.Base(path)) + if err := os.WriteFile(tokenFile, []byte(token), 0644); err != nil { + return fmt.Errorf("failed to write token file: %w", err) + } + + // 写入 Apache 配置 + conf := fmt.Sprintf(`Alias /.well-known/acme-challenge %s + + Require all granted + ForceType text/plain + +`, tokenDir, tokenDir) + + file, err := os.OpenFile(s.conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open apache config %q: %w", s.conf, err) + } + defer func(file *os.File) { _ = file.Close() }(file) + + if _, err = file.Write([]byte(conf)); err != nil { + return fmt.Errorf("failed to write to apache config %q: %w", s.conf, err) + } + + if err = systemctl.Reload("apache"); err != nil { + _, err = shell.Execf("apachectl -t") + return fmt.Errorf("failed to reload apache: %w", err) + } + + return nil +} + // CleanUp cleans up the HTTP server if it is the last one to finish. func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) error { + path := challenge.HTTP01ResourcePath() + token := challenge.KeyAuthorization + + if s.webServer == "apache" { + return s.cleanUpApache(path, token) + } + return s.cleanUpNginx(path, token) +} + +func (s httpSolver) cleanUpNginx(path, token string) error { conf, err := os.ReadFile(s.conf) if err != nil { return fmt.Errorf("failed to read nginx config %q: %w", s.conf, err) @@ -201,7 +318,7 @@ func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) error { default_type text/plain; return 200 %q; } -`, challenge.HTTP01ResourcePath(), challenge.KeyAuthorization) +`, path, token) newConf := strings.ReplaceAll(string(conf), target, "") if err = os.WriteFile(s.conf, []byte(newConf), 0600); err != nil { @@ -216,6 +333,39 @@ func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) error { return nil } +func (s httpSolver) cleanUpApache(path, token string) error { + tokenDir := filepath.Dir(s.conf) + "/acme-challenge" + + // 删除 token 文件 + tokenFile := filepath.Join(tokenDir, filepath.Base(path)) + _ = os.Remove(tokenFile) + + // 清理配置文件 + conf, err := os.ReadFile(s.conf) + if err != nil { + return fmt.Errorf("failed to read apache config %q: %w", s.conf, err) + } + + target := fmt.Sprintf(`Alias /.well-known/acme-challenge %s + + Require all granted + ForceType text/plain + +`, tokenDir, tokenDir) + + newConf := strings.ReplaceAll(string(conf), target, "") + if err = os.WriteFile(s.conf, []byte(newConf), 0600); err != nil { + return fmt.Errorf("failed to write to apache config %q: %w", s.conf, err) + } + + if err = systemctl.Reload("apache"); err != nil { + _, err = shell.Execf("apachectl -t") + return fmt.Errorf("failed to reload apache: %w", err) + } + + return nil +} + type dnsSolver struct { dns DnsType param DNSParam diff --git a/pkg/webserver/apache/proxy.go b/pkg/webserver/apache/proxy.go index 0d8be2551..e47311749 100644 --- a/pkg/webserver/apache/proxy.go +++ b/pkg/webserver/apache/proxy.go @@ -86,10 +86,10 @@ func parseProxyFile(filePath string) (*types.Proxy, error) { proxy.Host = matches[1] } - // 解析 SSLProxyEngine 和 ProxySSL* (SNI) + // 解析 SSLProxyEngine 和 SNI if regexp.MustCompile(`SSLProxyEngine\s+On`).MatchString(contentStr) { - // 尝试获取 SNI - sniPattern := regexp.MustCompile(`ProxyPassMatch.*ssl:([^/\s]+)`) + // 尝试从 # SNI: xxx 注释中获取 SNI + sniPattern := regexp.MustCompile(`#\s*SNI:\s*(\S+)`) if sm := sniPattern.FindStringSubmatch(contentStr); sm != nil { proxy.SNI = sm[1] } @@ -106,9 +106,11 @@ func parseProxyFile(filePath string) (*types.Proxy, error) { } // 解析 Substitute (响应内容替换) - subPattern := regexp.MustCompile(`Substitute\s+"s/([^/]+)/([^/]*)/[gin]*"`) - subMatches := subPattern.FindAllStringSubmatch(contentStr, -1) - for _, sm := range subMatches { + // 格式: Substitute "s|from|to|n" 或 Substitute "s/from/to/n" + // 使用 | 作为分隔符以支持包含 / 的内容 + subPatternPipe := regexp.MustCompile(`Substitute\s+"s\|([^|]*)\|([^|]*)\|[gin]*"`) + subMatchesPipe := subPatternPipe.FindAllStringSubmatch(contentStr, -1) + for _, sm := range subMatchesPipe { proxy.Replaces[sm[1]] = sm[2] } @@ -178,9 +180,6 @@ func generateProxyConfig(proxy types.Proxy) string { var sb strings.Builder location := proxy.Location - if location == "" { - location = "/" - } // 将 Nginx 风格的 location 转换为 Apache 格式 // ^~ / -> / // ~ ^/api -> /api (正则匹配需要使用 ProxyPassMatch) @@ -194,6 +193,12 @@ func generateProxyConfig(proxy types.Proxy) string { location = "/" + location } + // Pass 不以 / 结尾时,添加 / + // 垃圾 Apache 需要这样才不报错 + if !strings.HasSuffix(proxy.Pass, "/") { + proxy.Pass += "/" + } + sb.WriteString(fmt.Sprintf("# Reverse proxy: %s -> %s\n", location, proxy.Pass)) // 启用代理模块 @@ -210,17 +215,16 @@ func generateProxyConfig(proxy types.Proxy) string { sb.WriteString(" ProxyPreserveHost On\n") } - // 标准代理头 - sb.WriteString(" RequestHeader set X-Real-IP \"%{REMOTE_ADDR}e\"\n") - sb.WriteString(" RequestHeader set X-Forwarded-For \"%{X-Forwarded-For}e\"\n") - sb.WriteString(" RequestHeader set X-Forwarded-Proto \"%{REQUEST_SCHEME}e\"\n") - // SSL/SNI 配置 if proxy.SNI != "" || strings.HasPrefix(proxy.Pass, "https://") { sb.WriteString(" SSLProxyEngine On\n") sb.WriteString(" SSLProxyVerify none\n") sb.WriteString(" SSLProxyCheckPeerCN off\n") sb.WriteString(" SSLProxyCheckPeerName off\n") + if proxy.SNI != "" { + // 垃圾 Apache 不支持自定义 SNI,写这里只是备注一下 + sb.WriteString(fmt.Sprintf(" # SNI: %s\n", proxy.SNI)) + } } // Buffering 配置 @@ -241,7 +245,8 @@ func generateProxyConfig(proxy types.Proxy) string { sb.WriteString(" \n") sb.WriteString(" AddOutputFilterByType SUBSTITUTE text/html text/plain text/xml\n") for from, to := range proxy.Replaces { - sb.WriteString(fmt.Sprintf(" Substitute \"s/%s/%s/n\"\n", from, to)) + // 使用 | 作为分隔符以支持包含 / 的内容 + sb.WriteString(fmt.Sprintf(" Substitute \"s|%s|%s|n\"\n", from, to)) } sb.WriteString(" \n") } @@ -307,29 +312,25 @@ func parseBalancerFile(filePath string, name string) (*types.Upstream, error) { // // 解析 BalancerMember - memberPattern := regexp.MustCompile(`BalancerMember\s+(\S+)(?:\s+(.+))?`) - memberMatches := memberPattern.FindAllStringSubmatch(contentStr, -1) - for _, mm := range memberMatches { - addr := mm[1] - options := "" - if len(mm) > 2 { - options = strings.TrimSpace(mm[2]) + memberPattern := regexp.MustCompile(`^\s*BalancerMember\s+(\S+)(?:\s+(.*))?$`) + lines := strings.Split(contentStr, "\n") + for _, line := range lines { + if mm := memberPattern.FindStringSubmatch(line); mm != nil { + addr := mm[1] + options := "" + if len(mm) > 2 { + options = strings.TrimSpace(mm[2]) + } + upstream.Servers[addr] = options } - upstream.Servers[addr] = options } // 解析负载均衡方法 lbMethodPattern := regexp.MustCompile(`lbmethod=(\S+)`) if lm := lbMethodPattern.FindStringSubmatch(contentStr); lm != nil { - switch lm[1] { - case "byrequests": - upstream.Algo = "" - case "bytraffic": - upstream.Algo = "bytraffic" - case "bybusyness": - upstream.Algo = "least_conn" - case "heartbeat": - upstream.Algo = "heartbeat" + // byrequests 是默认值,不需要存储 + if lm[1] != "byrequests" { + upstream.Algo = lm[1] } } @@ -409,13 +410,8 @@ func generateBalancerConfig(upstream types.Upstream) string { // 负载均衡方法 lbMethod := "byrequests" // 默认轮询 - switch upstream.Algo { - case "least_conn": - lbMethod = "bybusyness" - case "bytraffic": - lbMethod = "bytraffic" - case "heartbeat": - lbMethod = "heartbeat" + if upstream.Algo != "" { + lbMethod = upstream.Algo } sb.WriteString(fmt.Sprintf(" ProxySet lbmethod=%s\n", lbMethod)) diff --git a/pkg/webserver/apache/vhost.go b/pkg/webserver/apache/vhost.go index 8a03cce37..e8a5a058f 100644 --- a/pkg/webserver/apache/vhost.go +++ b/pkg/webserver/apache/vhost.go @@ -177,7 +177,7 @@ func (v *baseVhost) SetListen(listens []types.Listen) error { } func (v *baseVhost) ServerName() []string { - var names []string + names := make([]string, 0) // 获取 ServerName serverName := v.vhost.GetDirectiveValue("ServerName") @@ -218,7 +218,7 @@ func (v *baseVhost) Index() []string { if values != nil { return values } - return nil + return []string{} } func (v *baseVhost) SetIndex(index []string) error { diff --git a/pkg/webserver/apache/vhost_test.go b/pkg/webserver/apache/vhost_test.go index 2cce54007..6598f7e0d 100644 --- a/pkg/webserver/apache/vhost_test.go +++ b/pkg/webserver/apache/vhost_test.go @@ -553,7 +553,7 @@ func (s *ProxyVhostTestSuite) TestUpstreams() { "http://127.0.0.1:8080": "loadfactor=5", "http://127.0.0.1:8081": "loadfactor=3", }, - Algo: "least_conn", + Algo: "bybusyness", Keepalive: 32, }, } @@ -578,7 +578,7 @@ func (s *ProxyVhostTestSuite) TestBalancerConfig() { Servers: map[string]string{ "http://127.0.0.1:8080": "loadfactor=5", }, - Algo: "least_conn", + Algo: "bybusyness", Keepalive: 16, }, } @@ -596,7 +596,7 @@ func (s *ProxyVhostTestSuite) TestBalancerConfig() { s.Contains(string(content), "balancer://mybackend") s.Contains(string(content), "BalancerMember") s.Contains(string(content), "http://127.0.0.1:8080") - s.Contains(string(content), "lbmethod=bybusyness") // least_conn 映射为 bybusyness + s.Contains(string(content), "lbmethod=bybusyness") } func (s *ProxyVhostTestSuite) TestClearUpstreams() { @@ -612,3 +612,113 @@ func (s *ProxyVhostTestSuite) TestClearUpstreams() { s.NoError(s.vhost.ClearUpstreams()) s.Empty(s.vhost.Upstreams()) } + +func (s *ProxyVhostTestSuite) TestProxySNI() { + // 测试 SNI 配置的写入和解析 + proxies := []types.Proxy{ + { + Location: "/", + Pass: "https://backend:443/", + SNI: "backend.example.com", + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 读取配置文件内容,验证 SNI 已写入 + siteDir := filepath.Join(s.configDir, "site") + content, err := os.ReadFile(filepath.Join(siteDir, "200-proxy.conf")) + s.NoError(err) + + s.Contains(string(content), "SSLProxyEngine On") + s.Contains(string(content), "# SNI: backend.example.com") + s.Contains(string(content), "RequestHeader set Host \"backend.example.com\"") + + // 验证可以解析回来 + got := s.vhost.Proxies() + s.Require().Len(got, 1) + s.Equal("backend.example.com", got[0].SNI) +} + +func (s *ProxyVhostTestSuite) TestProxySubstitute() { + // 测试内容替换的写入和解析 + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend:8080/", + Replaces: map[string]string{ + "http://old.example.com": "https://new.example.com", + "foo": "bar", + }, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 读取配置文件内容,验证 Substitute 已写入 + siteDir := filepath.Join(s.configDir, "site") + content, err := os.ReadFile(filepath.Join(siteDir, "200-proxy.conf")) + s.NoError(err) + + s.Contains(string(content), "mod_substitute") + s.Contains(string(content), "Substitute") + + // 验证可以解析回来 + got := s.vhost.Proxies() + s.Require().Len(got, 1) + s.Require().NotNil(got[0].Replaces) + s.Equal("https://new.example.com", got[0].Replaces["http://old.example.com"]) + s.Equal("bar", got[0].Replaces["foo"]) +} + +func (s *ProxyVhostTestSuite) TestProxySubstituteWithSlash() { + // 测试包含 / 的内容替换 + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend:8080/", + Replaces: map[string]string{ + "http://old.example.com/path/to/resource": "https://new.example.com/new/path", + }, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 读取配置文件内容 + siteDir := filepath.Join(s.configDir, "site") + content, err := os.ReadFile(filepath.Join(siteDir, "200-proxy.conf")) + s.NoError(err) + + // 验证使用 | 作为分隔符 + s.Contains(string(content), "Substitute \"s|http://old.example.com/path/to/resource|https://new.example.com/new/path|n\"") + + // 验证可以解析回来 + got := s.vhost.Proxies() + s.Require().Len(got, 1) + s.Require().NotNil(got[0].Replaces) + s.Equal("https://new.example.com/new/path", got[0].Replaces["http://old.example.com/path/to/resource"]) +} + +func (s *ProxyVhostTestSuite) TestUpstreamMultipleServers() { + // 测试多个 BalancerMember 的解析 + upstreams := []types.Upstream{ + { + Name: "test_upstream", + Servers: map[string]string{ + "127.0.0.1:8080": "", + "127.0.0.1:8081": "", + "127.0.0.1:8082": "loadfactor=5", + }, + Keepalive: 32, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 验证可以解析回来 + got := s.vhost.Upstreams() + s.Require().Len(got, 1) + s.Equal("test_upstream", got[0].Name) + s.Len(got[0].Servers, 3) + s.Contains(got[0].Servers, "127.0.0.1:8080") + s.Contains(got[0].Servers, "127.0.0.1:8081") + s.Contains(got[0].Servers, "127.0.0.1:8082") + s.Equal("loadfactor=5", got[0].Servers["127.0.0.1:8082"]) +} diff --git a/pkg/webserver/nginx/proxy.go b/pkg/webserver/nginx/proxy.go index d2b1de9d3..ca10a27fc 100644 --- a/pkg/webserver/nginx/proxy.go +++ b/pkg/webserver/nginx/proxy.go @@ -212,9 +212,6 @@ func generateProxyConfig(proxy types.Proxy) string { var sb strings.Builder location := proxy.Location - if location == "" { - location = "/" - } sb.WriteString(fmt.Sprintf("location %s {\n", location)) diff --git a/web/src/views/website/EditView.vue b/web/src/views/website/EditView.vue index 7d725b737..2346a00ed 100644 --- a/web/src/views/website/EditView.vue +++ b/web/src/views/website/EditView.vue @@ -498,13 +498,21 @@ const updateTimeoutUnit = (proxy: any, unit: string) => {