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) => {