diff --git a/pkg/webserver/apache/data.go b/pkg/webserver/apache/data.go index 0d409b0f..a1caa739 100644 --- a/pkg/webserver/apache/data.go +++ b/pkg/webserver/apache/data.go @@ -1,7 +1,7 @@ package apache // DisableConfName 禁用配置文件名 -const DisableConfName = "00-disable.conf" +const DisableConfName = "000-disable.conf" // DisableConfContent 禁用配置内容 const DisableConfContent = `# 网站已停止 @@ -9,6 +9,14 @@ RewriteEngine on RewriteRule ^.*$ - [R=503,L] ` +// 配置文件序号范围 +const ( + RedirectStartNum = 100 // 重定向配置起始序号 (100-199) + RedirectEndNum = 199 + ProxyStartNum = 200 // 代理配置起始序号 (200-299) + ProxyEndNum = 299 +) + // DefaultVhostConf 默认配置模板 const DefaultVhostConf = ` ServerName localhost @@ -19,7 +27,7 @@ const DefaultVhostConf = ` CustomLog /opt/ace/sites/default/log/access.log combined # custom configs - IncludeOptional /opt/ace/sites/default/config/server.d/*.conf + IncludeOptional /opt/ace/sites/default/config/vhost/*.conf Options -Indexes +FollowSymLinks diff --git a/pkg/webserver/apache/proxy.go b/pkg/webserver/apache/proxy.go new file mode 100644 index 00000000..3b64d4a0 --- /dev/null +++ b/pkg/webserver/apache/proxy.go @@ -0,0 +1,433 @@ +package apache + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/acepanel/panel/pkg/webserver/types" +) + +// proxyFilePattern 匹配代理配置文件名 (200-299) +var proxyFilePattern = regexp.MustCompile(`^(\d{3})-proxy\.conf$`) + +// balancerFilePattern 匹配负载均衡配置文件名 +var balancerFilePattern = regexp.MustCompile(`^(\d{3})-balancer-(.+)\.conf$`) + +// parseProxyFiles 从 vhost 目录解析所有代理配置 +func parseProxyFiles(vhostDir string) ([]types.Proxy, error) { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var proxies []types.Proxy + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := proxyFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num < ProxyStartNum || num > ProxyEndNum { + continue + } + + filePath := filepath.Join(vhostDir, entry.Name()) + proxy, err := parseProxyFile(filePath) + if err != nil { + continue // 跳过解析失败的文件 + } + if proxy != nil { + proxies = append(proxies, *proxy) + } + } + + return proxies, nil +} + +// parseProxyFile 解析单个代理配置文件 +func parseProxyFile(filePath string) (*types.Proxy, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + proxy := &types.Proxy{ + Replaces: make(map[string]string), + } + + // 解析 ProxyPass 指令 + // ProxyPass / http://backend/ + proxyPassPattern := regexp.MustCompile(`ProxyPass\s+(\S+)\s+(\S+)`) + if matches := proxyPassPattern.FindStringSubmatch(contentStr); matches != nil { + proxy.Location = matches[1] + proxy.Pass = matches[2] + } + + // 解析 ProxyPreserveHost + if regexp.MustCompile(`ProxyPreserveHost\s+On`).MatchString(contentStr) { + // Host 由客户端提供 + } + + // 解析 RequestHeader set Host + hostPattern := regexp.MustCompile(`RequestHeader\s+set\s+Host\s+"([^"]+)"`) + if matches := hostPattern.FindStringSubmatch(contentStr); matches != nil { + proxy.Host = matches[1] + } + + // 解析 SSLProxyEngine 和 ProxySSL* (SNI) + if regexp.MustCompile(`SSLProxyEngine\s+On`).MatchString(contentStr) { + // 尝试获取 SNI + sniPattern := regexp.MustCompile(`ProxyPassMatch.*ssl:([^/\s]+)`) + if sm := sniPattern.FindStringSubmatch(contentStr); sm != nil { + proxy.SNI = sm[1] + } + } + + // 解析 ProxyIOBufferSize (buffering) + if regexp.MustCompile(`ProxyIOBufferSize`).MatchString(contentStr) { + proxy.Buffering = true + } + + // 解析 CacheEnable + if regexp.MustCompile(`CacheEnable`).MatchString(contentStr) { + proxy.Cache = true + } + + // 解析 ProxyTimeout (resolver timeout) + timeoutPattern := regexp.MustCompile(`ProxyTimeout\s+(\d+)`) + if tm := timeoutPattern.FindStringSubmatch(contentStr); tm != nil { + timeout, _ := strconv.Atoi(tm[1]) + proxy.ResolverTimeout = time.Duration(timeout) * time.Second + } + + // 解析 Substitute (响应内容替换) + subPattern := regexp.MustCompile(`Substitute\s+"s/([^/]+)/([^/]*)/[gin]*"`) + subMatches := subPattern.FindAllStringSubmatch(contentStr, -1) + for _, sm := range subMatches { + proxy.Replaces[sm[1]] = sm[2] + } + + return proxy, nil +} + +// writeProxyFiles 将代理配置写入文件 +func writeProxyFiles(vhostDir string, proxies []types.Proxy) error { + // 删除现有的代理配置文件 (200-299) + if err := clearProxyFiles(vhostDir); err != nil { + return err + } + + // 写入新的配置文件 + for i, proxy := range proxies { + num := ProxyStartNum + i + if num > ProxyEndNum { + return fmt.Errorf("proxy rules exceed limit (%d)", ProxyEndNum-ProxyStartNum+1) + } + + fileName := fmt.Sprintf("%03d-proxy.conf", num) + filePath := filepath.Join(vhostDir, fileName) + + content := generateProxyConfig(proxy) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write proxy config: %w", err) + } + } + + return nil +} + +// clearProxyFiles 清除所有代理配置文件 +func clearProxyFiles(vhostDir string) error { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := proxyFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num >= ProxyStartNum && num <= ProxyEndNum { + filePath := filepath.Join(vhostDir, entry.Name()) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete proxy config: %w", err) + } + } + } + + return nil +} + +// generateProxyConfig 生成代理配置内容 +func generateProxyConfig(proxy types.Proxy) string { + var sb strings.Builder + + location := proxy.Location + if location == "" { + location = "/" + } + + sb.WriteString(fmt.Sprintf("# Reverse proxy: %s -> %s\n", location, proxy.Pass)) + + // 启用代理模块 + sb.WriteString("\n") + + // ProxyPass 和 ProxyPassReverse + sb.WriteString(fmt.Sprintf(" ProxyPass %s %s\n", location, proxy.Pass)) + sb.WriteString(fmt.Sprintf(" ProxyPassReverse %s %s\n", location, proxy.Pass)) + + // Host 配置 + if proxy.Host != "" { + sb.WriteString(fmt.Sprintf(" RequestHeader set Host \"%s\"\n", proxy.Host)) + } else { + 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") + } + + // Buffering 配置 + if proxy.Buffering { + sb.WriteString(" ProxyIOBufferSize 65536\n") + } + + // Timeout 配置 + if proxy.ResolverTimeout > 0 { + sb.WriteString(fmt.Sprintf(" ProxyTimeout %d\n", int(proxy.ResolverTimeout.Seconds()))) + } + + // Cache 配置 + if proxy.Cache { + sb.WriteString(" \n") + sb.WriteString(fmt.Sprintf(" CacheEnable disk %s\n", location)) + sb.WriteString(" CacheDefaultExpire 600\n") + sb.WriteString(" \n") + } + + // 响应内容替换 + if len(proxy.Replaces) > 0 { + 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(" \n") + } + + sb.WriteString("\n") + + return sb.String() +} + +// parseBalancerFiles 从 global 目录解析所有负载均衡配置(Apache 的 upstream 等价物) +func parseBalancerFiles(globalDir string) (map[string]types.Upstream, error) { + entries, err := os.ReadDir(globalDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + upstreams := make(map[string]types.Upstream) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := balancerFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + name := matches[2] + filePath := filepath.Join(globalDir, entry.Name()) + upstream, err := parseBalancerFile(filePath) + if err != nil { + continue // 跳过解析失败的文件 + } + if upstream != nil { + upstreams[name] = *upstream + } + } + + return upstreams, nil +} + +// parseBalancerFile 解析单个负载均衡配置文件 +func parseBalancerFile(filePath string) (*types.Upstream, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + upstream := &types.Upstream{ + Servers: make(map[string]string), + } + + // 解析 块 + // + // BalancerMember http://127.0.0.1:8080 loadfactor=5 + // BalancerMember http://127.0.0.1:8081 loadfactor=3 + // ProxySet lbmethod=byrequests + // + + // 解析 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]) + } + 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" + } + } + + // 解析连接池大小 (类似 keepalive) + maxPattern := regexp.MustCompile(`max=(\d+)`) + if mm := maxPattern.FindStringSubmatch(contentStr); mm != nil { + upstream.Keepalive, _ = strconv.Atoi(mm[1]) + } + + return upstream, nil +} + +// writeBalancerFiles 将负载均衡配置写入文件 +func writeBalancerFiles(globalDir string, upstreams map[string]types.Upstream) error { + // 删除现有的负载均衡配置文件 + if err := clearBalancerFiles(globalDir); err != nil { + return err + } + + // 写入新的配置文件 + num := 100 + for name, upstream := range upstreams { + fileName := fmt.Sprintf("%03d-balancer-%s.conf", num, name) + filePath := filepath.Join(globalDir, fileName) + + content := generateBalancerConfig(name, upstream) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write balancer config: %w", err) + } + num++ + } + + return nil +} + +// clearBalancerFiles 清除所有负载均衡配置文件 +func clearBalancerFiles(globalDir string) error { + entries, err := os.ReadDir(globalDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if balancerFilePattern.MatchString(entry.Name()) { + filePath := filepath.Join(globalDir, entry.Name()) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete balancer config: %w", err) + } + } + } + + return nil +} + +// generateBalancerConfig 生成负载均衡配置内容 +func generateBalancerConfig(name string, upstream types.Upstream) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("# Load balancer: %s\n", name)) + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf(" \n", name)) + + // 服务器列表 + for addr, options := range upstream.Servers { + if options != "" { + sb.WriteString(fmt.Sprintf(" BalancerMember %s %s\n", addr, options)) + } else { + sb.WriteString(fmt.Sprintf(" BalancerMember %s\n", addr)) + } + } + + // 负载均衡方法 + lbMethod := "byrequests" // 默认轮询 + switch upstream.Algo { + case "least_conn": + lbMethod = "bybusyness" + case "bytraffic": + lbMethod = "bytraffic" + case "heartbeat": + lbMethod = "heartbeat" + } + sb.WriteString(fmt.Sprintf(" ProxySet lbmethod=%s\n", lbMethod)) + + // 连接池配置 + if upstream.Keepalive > 0 { + sb.WriteString(fmt.Sprintf(" ProxySet max=%d\n", upstream.Keepalive)) + } + + sb.WriteString(" \n") + sb.WriteString("\n") + + return sb.String() +} diff --git a/pkg/webserver/apache/redirect.go b/pkg/webserver/apache/redirect.go new file mode 100644 index 00000000..50bf49cc --- /dev/null +++ b/pkg/webserver/apache/redirect.go @@ -0,0 +1,228 @@ +package apache + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/acepanel/panel/pkg/webserver/types" +) + +// redirectFilePattern 匹配重定向配置文件名 (100-199) +var redirectFilePattern = regexp.MustCompile(`^(\d{3})-redirect\.conf$`) + +// parseRedirectFiles 从 vhost 目录解析所有重定向配置 +func parseRedirectFiles(vhostDir string) ([]types.Redirect, error) { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var redirects []types.Redirect + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := redirectFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num < RedirectStartNum || num > RedirectEndNum { + continue + } + + filePath := filepath.Join(vhostDir, entry.Name()) + redirect, err := parseRedirectFile(filePath) + if err != nil { + continue // 跳过解析失败的文件 + } + if redirect != nil { + redirects = append(redirects, *redirect) + } + } + + return redirects, nil +} + +// parseRedirectFile 解析单个重定向配置文件 +func parseRedirectFile(filePath string) (*types.Redirect, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + + // 解析 Redirect 指令: Redirect 308 /old /new + redirectPattern := regexp.MustCompile(`Redirect\s+(\d+)\s+(\S+)\s+(\S+)`) + if matches := redirectPattern.FindStringSubmatch(contentStr); matches != nil { + statusCode, _ := strconv.Atoi(matches[1]) + return &types.Redirect{ + Type: types.RedirectTypeURL, + From: matches[2], + To: matches[3], + StatusCode: statusCode, + }, nil + } + + // 解析 RedirectMatch 指令: RedirectMatch 308 ^/old(.*)$ /new$1 + redirectMatchPattern := regexp.MustCompile(`RedirectMatch\s+(\d+)\s+(\S+)\s+(\S+)`) + if matches := redirectMatchPattern.FindStringSubmatch(contentStr); matches != nil { + statusCode, _ := strconv.Atoi(matches[1]) + return &types.Redirect{ + Type: types.RedirectTypeURL, + From: matches[2], + To: matches[3], + KeepURI: strings.Contains(matches[3], "$1"), + StatusCode: statusCode, + }, nil + } + + // 解析 RewriteRule Host 重定向 + // RewriteCond %{HTTP_HOST} ^old\.example\.com$ + // RewriteRule ^(.*)$ https://new.example.com$1 [R=308,L] + hostRewritePattern := regexp.MustCompile(`RewriteCond\s+%\{HTTP_HOST}\s+\^?([^$\s]+)\$?\s*\[?NC]?\s*\n\s*RewriteRule\s+\^\(\.\*\)\$\s+([^\s\[]+)\s*\[R=(\d+)`) + if matches := hostRewritePattern.FindStringSubmatch(contentStr); matches != nil { + statusCode, _ := strconv.Atoi(matches[3]) + host := strings.ReplaceAll(matches[1], `\.`, ".") + return &types.Redirect{ + Type: types.RedirectTypeHost, + From: host, + To: matches[2], + KeepURI: strings.Contains(matches[2], "$1"), + StatusCode: statusCode, + }, nil + } + + // 解析 ErrorDocument 404 重定向 + // ErrorDocument 404 /custom-404 + errorDocPattern := regexp.MustCompile(`ErrorDocument\s+404\s+(\S+)`) + if matches := errorDocPattern.FindStringSubmatch(contentStr); matches != nil { + return &types.Redirect{ + Type: types.RedirectType404, + To: matches[1], + StatusCode: 308, + }, nil + } + + return nil, nil +} + +// writeRedirectFiles 将重定向配置写入文件 +func writeRedirectFiles(vhostDir string, redirects []types.Redirect) error { + // 删除现有的重定向配置文件 (100-199) + if err := clearRedirectFiles(vhostDir); err != nil { + return err + } + + // 写入新的配置文件 + for i, redirect := range redirects { + num := RedirectStartNum + i + if num > RedirectEndNum { + return fmt.Errorf("redirect rules exceed limit (%d)", RedirectEndNum-RedirectStartNum+1) + } + + fileName := fmt.Sprintf("%03d-redirect.conf", num) + filePath := filepath.Join(vhostDir, fileName) + + content := generateRedirectConfig(redirect) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write redirect config: %w", err) + } + } + + return nil +} + +// clearRedirectFiles 清除所有重定向配置文件 +func clearRedirectFiles(vhostDir string) error { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := redirectFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num >= RedirectStartNum && num <= RedirectEndNum { + filePath := filepath.Join(vhostDir, entry.Name()) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete redirect config: %w", err) + } + } + } + + return nil +} + +// generateRedirectConfig 生成重定向配置内容 +func generateRedirectConfig(redirect types.Redirect) string { + statusCode := redirect.StatusCode + if statusCode == 0 { + statusCode = 308 // 默认使用 308 永久重定向 + } + + var sb strings.Builder + + switch redirect.Type { + case types.RedirectTypeURL: + // URL 重定向 + sb.WriteString(fmt.Sprintf("# URL redirect: %s -> %s\n", redirect.From, redirect.To)) + if redirect.KeepURI { + // 使用 RedirectMatch 保持 URI + from := redirect.From + if !strings.HasPrefix(from, "^") { + from = "^" + from + } + if !strings.HasSuffix(from, "(.*)$") && !strings.HasSuffix(from, "$") { + from = from + "(.*)$" + } + to := redirect.To + if !strings.HasSuffix(to, "$1") { + to = to + "$1" + } + sb.WriteString(fmt.Sprintf("RedirectMatch %d %s %s\n", statusCode, from, to)) + } else { + sb.WriteString(fmt.Sprintf("Redirect %d %s %s\n", statusCode, redirect.From, redirect.To)) + } + + case types.RedirectTypeHost: + // Host 重定向 + sb.WriteString(fmt.Sprintf("# Host redirect: %s -> %s\n", redirect.From, redirect.To)) + sb.WriteString("RewriteEngine on\n") + escapedHost := strings.ReplaceAll(redirect.From, ".", `\.`) + sb.WriteString(fmt.Sprintf("RewriteCond %%{HTTP_HOST} ^%s$ [NC]\n", escapedHost)) + if redirect.KeepURI { + sb.WriteString(fmt.Sprintf("RewriteRule ^(.*)$ %s$1 [R=%d,L]\n", redirect.To, statusCode)) + } else { + sb.WriteString(fmt.Sprintf("RewriteRule ^(.*)$ %s [R=%d,L]\n", redirect.To, statusCode)) + } + + case types.RedirectType404: + // 404 重定向 + sb.WriteString(fmt.Sprintf("# 404 redirect -> %s\n", redirect.To)) + sb.WriteString(fmt.Sprintf("ErrorDocument 404 %s\n", redirect.To)) + } + + return sb.String() +} diff --git a/pkg/webserver/apache/vhost.go b/pkg/webserver/apache/vhost.go index 898f944f..9456d141 100644 --- a/pkg/webserver/apache/vhost.go +++ b/pkg/webserver/apache/vhost.go @@ -11,17 +11,35 @@ import ( "github.com/acepanel/panel/pkg/webserver/types" ) -// Vhost Apache 虚拟主机实现 -type Vhost struct { +// StaticVhost 纯静态虚拟主机 +type StaticVhost struct { + *baseVhost +} + +// PHPVhost PHP 虚拟主机 +type PHPVhost struct { + *baseVhost +} + +// ProxyVhost 反向代理虚拟主机 +type ProxyVhost struct { + *baseVhost +} + +// baseVhost Apache 虚拟主机基础实现 +type baseVhost struct { config *Config vhost *VirtualHost configDir string // 配置目录 } -// NewVhost 创建 Apache 虚拟主机实例 -// configDir: 配置目录路径 -func NewVhost(configDir string) (*Vhost, error) { - v := &Vhost{ +// newBaseVhost 创建基础虚拟主机实例 +func newBaseVhost(configDir string) (*baseVhost, error) { + if configDir == "" { + return nil, fmt.Errorf("config directory is required") + } + + v := &baseVhost{ configDir: configDir, } @@ -29,14 +47,12 @@ func NewVhost(configDir string) (*Vhost, error) { var config *Config var err error - if v.configDir != "" { - // 从配置目录加载主配置文件 - configFile := filepath.Join(v.configDir, "apache.conf") - if _, statErr := os.Stat(configFile); statErr == nil { - config, err = ParseFile(configFile) - if err != nil { - return nil, fmt.Errorf("failed to parse apache config: %w", err) - } + // 从配置目录加载主配置文件 + configFile := filepath.Join(v.configDir, "apache.conf") + if _, statErr := os.Stat(configFile); statErr == nil { + config, err = ParseFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to parse apache config: %w", err) } } @@ -61,17 +77,42 @@ func NewVhost(configDir string) (*Vhost, error) { return v, nil } -// ========== VhostCore 接口实现 ========== +// NewStaticVhost 创建纯静态虚拟主机实例 +func NewStaticVhost(configDir string) (*StaticVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &StaticVhost{baseVhost: base}, nil +} -func (v *Vhost) Enable() bool { +// NewPHPVhost 创建 PHP 虚拟主机实例 +func NewPHPVhost(configDir string) (*PHPVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &PHPVhost{baseVhost: base}, nil +} + +// NewProxyVhost 创建反向代理虚拟主机实例 +func NewProxyVhost(configDir string) (*ProxyVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &ProxyVhost{baseVhost: base}, nil +} + +func (v *baseVhost) Enable() bool { // 检查禁用配置文件是否存在 - disableFile := filepath.Join(v.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(v.configDir, "vhost", DisableConfName) _, err := os.Stat(disableFile) return os.IsNotExist(err) } -func (v *Vhost) SetEnable(enable bool, _ ...string) error { - serverDir := filepath.Join(v.configDir, "server.d") +func (v *baseVhost) SetEnable(enable bool, _ ...string) error { + serverDir := filepath.Join(v.configDir, "vhost") disableFile := filepath.Join(serverDir, DisableConfName) if enable { @@ -83,20 +124,14 @@ func (v *Vhost) SetEnable(enable bool, _ ...string) error { } // 禁用:创建禁用配置文件 - // 确保目录存在 - if err := os.MkdirAll(serverDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // 写入禁用配置 if err := os.WriteFile(disableFile, []byte(DisableConfContent), 0644); err != nil { - return fmt.Errorf("写入禁用配置失败: %w", err) + return fmt.Errorf("failed to write disable config: %w", err) } return nil } -func (v *Vhost) Listen() []types.Listen { +func (v *baseVhost) Listen() []types.Listen { var result []types.Listen // Apache 的监听配置通常在 VirtualHost 的参数中 @@ -120,7 +155,7 @@ func (v *Vhost) Listen() []types.Listen { return result } -func (v *Vhost) SetListen(listens []types.Listen) error { +func (v *baseVhost) SetListen(listens []types.Listen) error { var args []string for _, l := range listens { args = append(args, l.Address) @@ -129,7 +164,7 @@ func (v *Vhost) SetListen(listens []types.Listen) error { return nil } -func (v *Vhost) ServerName() []string { +func (v *baseVhost) ServerName() []string { var names []string // 获取 ServerName @@ -147,7 +182,7 @@ func (v *Vhost) ServerName() []string { return names } -func (v *Vhost) SetServerName(serverName []string) error { +func (v *baseVhost) SetServerName(serverName []string) error { if len(serverName) == 0 { return nil } @@ -166,7 +201,7 @@ func (v *Vhost) SetServerName(serverName []string) error { return nil } -func (v *Vhost) Index() []string { +func (v *baseVhost) Index() []string { values := v.vhost.GetDirectiveValues("DirectoryIndex") if values != nil { return values @@ -174,7 +209,7 @@ func (v *Vhost) Index() []string { return nil } -func (v *Vhost) SetIndex(index []string) error { +func (v *baseVhost) SetIndex(index []string) error { if len(index) == 0 { v.vhost.RemoveDirective("DirectoryIndex") return nil @@ -183,11 +218,11 @@ func (v *Vhost) SetIndex(index []string) error { return nil } -func (v *Vhost) Root() string { +func (v *baseVhost) Root() string { return v.vhost.GetDirectiveValue("DocumentRoot") } -func (v *Vhost) SetRoot(root string) error { +func (v *baseVhost) SetRoot(root string) error { v.vhost.SetDirective("DocumentRoot", root) // 同时更新 Directory 块 @@ -210,7 +245,7 @@ func (v *Vhost) SetRoot(root string) error { return nil } -func (v *Vhost) Includes() []types.IncludeFile { +func (v *baseVhost) Includes() []types.IncludeFile { var result []types.IncludeFile // 获取所有 Include 和 IncludeOptional 指令 @@ -232,7 +267,7 @@ func (v *Vhost) Includes() []types.IncludeFile { return result } -func (v *Vhost) SetIncludes(includes []types.IncludeFile) error { +func (v *baseVhost) SetIncludes(includes []types.IncludeFile) error { // 删除现有的 Include 指令 v.vhost.RemoveDirectives("Include") v.vhost.RemoveDirectives("IncludeOptional") @@ -245,76 +280,48 @@ func (v *Vhost) SetIncludes(includes []types.IncludeFile) error { return nil } -func (v *Vhost) AccessLog() string { +func (v *baseVhost) AccessLog() string { return v.vhost.GetDirectiveValue("CustomLog") } -func (v *Vhost) SetAccessLog(accessLog string) error { +func (v *baseVhost) SetAccessLog(accessLog string) error { v.vhost.SetDirective("CustomLog", accessLog, "combined") return nil } -func (v *Vhost) ErrorLog() string { +func (v *baseVhost) ErrorLog() string { return v.vhost.GetDirectiveValue("ErrorLog") } -func (v *Vhost) SetErrorLog(errorLog string) error { +func (v *baseVhost) SetErrorLog(errorLog string) error { v.vhost.SetDirective("ErrorLog", errorLog) return nil } -func (v *Vhost) Save() error { - if v.configDir == "" { - return fmt.Errorf("配置目录为空,无法保存") - } - +func (v *baseVhost) Save() error { configFile := filepath.Join(v.configDir, "apache.conf") content := v.config.Export() if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { - return fmt.Errorf("保存配置文件失败: %w", err) + return fmt.Errorf("failed to save config file: %w", err) } return nil } -func (v *Vhost) Reload() error { - // 重载 Apache 配置 - cmds := []string{ - "/opt/ace/apps/apache/bin/apachectl graceful", - "/usr/sbin/apachectl graceful", - "apachectl graceful", - "systemctl reload apache2", - "systemctl reload httpd", +func (v *baseVhost) Reload() error { + parts := strings.Fields("systemctl reload httpd") + if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil { + return fmt.Errorf("failed to reload apache config: %w", err) } - var lastErr error - for _, cmd := range cmds { - parts := strings.Fields(cmd) - if len(parts) < 1 { - continue - } - - // 检查命令是否存在 - if _, err := os.Stat(parts[0]); err == nil || parts[0] == "systemctl" { - err := exec.Command(parts[0], parts[1:]...).Run() - if err == nil { - return nil - } - lastErr = err - } - } - - if lastErr != nil { - return fmt.Errorf("重载 Apache 配置失败: %w", lastErr) - } - return fmt.Errorf("未找到 apachectl 或 apache2 命令") + return nil } -func (v *Vhost) Reset() error { +func (v *baseVhost) Reset() error { // 重置配置为默认值 config, err := ParseString(DefaultVhostConf) if err != nil { - return fmt.Errorf("重置配置失败: %w", err) + return fmt.Errorf("failed to reset config: %w", err) } v.config = config @@ -325,15 +332,13 @@ func (v *Vhost) Reset() error { return nil } -// ========== VhostSSL 接口实现 ========== - -func (v *Vhost) HTTPS() bool { +func (v *baseVhost) HTTPS() bool { // 检查是否有 SSL 相关配置 return v.vhost.HasDirective("SSLEngine") && strings.EqualFold(v.vhost.GetDirectiveValue("SSLEngine"), "on") } -func (v *Vhost) SSLConfig() *types.SSLConfig { +func (v *baseVhost) SSLConfig() *types.SSLConfig { if !v.HTTPS() { return nil } @@ -376,9 +381,9 @@ func (v *Vhost) SSLConfig() *types.SSLConfig { return config } -func (v *Vhost) SetSSLConfig(cfg *types.SSLConfig) error { +func (v *baseVhost) SetSSLConfig(cfg *types.SSLConfig) error { if cfg == nil { - return fmt.Errorf("SSL 配置不能为空") + return fmt.Errorf("SSL config cannot be nil") } // 启用 SSL @@ -449,7 +454,7 @@ func (v *Vhost) SetSSLConfig(cfg *types.SSLConfig) error { return nil } -func (v *Vhost) ClearHTTPS() error { +func (v *baseVhost) ClearHTTPS() error { // 移除 SSL 相关指令 v.vhost.RemoveDirective("SSLEngine") v.vhost.RemoveDirective("SSLCertificateFile") @@ -491,9 +496,94 @@ func (v *Vhost) ClearHTTPS() error { return nil } -// ========== VhostPHP 接口实现 ========== +func (v *baseVhost) RateLimit() *types.RateLimit { + // Apache 使用 mod_ratelimit + rate := v.vhost.GetDirectiveValue("SetOutputFilter") + if rate != "RATE_LIMIT" { + return nil + } -func (v *Vhost) PHP() int { + rateLimit := &types.RateLimit{ + Options: make(map[string]string), + } + + // 获取速率限制值 + rateValue := v.vhost.GetDirectiveValue("SetEnv") + if rateValue != "" { + rateLimit.Rate = rateValue + } + + return rateLimit +} + +func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error { + if limit == nil { + // 清除限速配置 + v.vhost.RemoveDirective("SetOutputFilter") + v.vhost.RemoveDirectives("SetEnv") + return nil + } + + // 设置 mod_ratelimit + v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT") + if limit.Rate != "" { + v.vhost.SetDirective("SetEnv", "rate-limit", limit.Rate) + } + + return nil +} + +func (v *baseVhost) BasicAuth() map[string]string { + authType := v.vhost.GetDirectiveValue("AuthType") + if authType == "" || !strings.EqualFold(authType, "Basic") { + return nil + } + + return map[string]string{ + "realm": v.vhost.GetDirectiveValue("AuthName"), + "user_file": v.vhost.GetDirectiveValue("AuthUserFile"), + } +} + +func (v *baseVhost) SetBasicAuth(auth map[string]string) error { + if auth == nil || len(auth) == 0 { + // 清除基本认证配置 + v.vhost.RemoveDirective("AuthType") + v.vhost.RemoveDirective("AuthName") + v.vhost.RemoveDirective("AuthUserFile") + v.vhost.RemoveDirective("Require") + return nil + } + + realm := auth["realm"] + userFile := auth["user_file"] + + if realm == "" { + realm = "Restricted" + } + + v.vhost.SetDirective("AuthType", "Basic") + v.vhost.SetDirective("AuthName", fmt.Sprintf(`"%s"`, realm)) + v.vhost.SetDirective("AuthUserFile", userFile) + v.vhost.SetDirective("Require", "valid-user") + + return nil +} + +func (v *baseVhost) Redirects() []types.Redirect { + vhostDir := filepath.Join(v.configDir, "vhost") + redirects, _ := parseRedirectFiles(vhostDir) + return redirects +} + +func (v *baseVhost) SetRedirects(redirects []types.Redirect) error { + vhostDir := filepath.Join(v.configDir, "vhost") + return writeRedirectFiles(vhostDir, redirects) +} + +// ========== PHPVhost ========== + +func (v *PHPVhost) PHP() int { // Apache 通常通过 FilesMatch 块配置 PHP // 或者通过 SetHandler 指令 handler := v.vhost.GetDirectiveValue("SetHandler") @@ -540,7 +630,7 @@ func (v *Vhost) PHP() int { return 0 } -func (v *Vhost) SetPHP(version int) error { +func (v *PHPVhost) SetPHP(version int) error { // 移除现有的 PHP 配置 v.vhost.RemoveDirective("SetHandler") @@ -577,78 +667,36 @@ func (v *Vhost) SetPHP(version int) error { return nil } -// ========== VhostAdvanced 接口实现 ========== +// ========== ProxyVhost ========== -func (v *Vhost) RateLimit() *types.RateLimit { - // Apache 使用 mod_ratelimit - rate := v.vhost.GetDirectiveValue("SetOutputFilter") - if rate != "RATE_LIMIT" { - return nil - } - - rateLimit := &types.RateLimit{ - Options: make(map[string]string), - } - - // 获取速率限制值 - rateValue := v.vhost.GetDirectiveValue("SetEnv") - if rateValue != "" { - rateLimit.Rate = rateValue - } - - return rateLimit +func (v *ProxyVhost) Proxies() []types.Proxy { + vhostDir := filepath.Join(v.configDir, "vhost") + proxies, _ := parseProxyFiles(vhostDir) + return proxies } -func (v *Vhost) SetRateLimit(limit *types.RateLimit) error { - if limit == nil { - // 清除限速配置 - v.vhost.RemoveDirective("SetOutputFilter") - v.vhost.RemoveDirectives("SetEnv") - return nil - } - - // 设置 mod_ratelimit - v.vhost.SetDirective("SetOutputFilter", "RATE_LIMIT") - if limit.Rate != "" { - v.vhost.SetDirective("SetEnv", "rate-limit", limit.Rate) - } - - return nil +func (v *ProxyVhost) SetProxies(proxies []types.Proxy) error { + vhostDir := filepath.Join(v.configDir, "vhost") + return writeProxyFiles(vhostDir, proxies) } -func (v *Vhost) BasicAuth() map[string]string { - authType := v.vhost.GetDirectiveValue("AuthType") - if authType == "" || !strings.EqualFold(authType, "Basic") { - return nil - } - - return map[string]string{ - "realm": v.vhost.GetDirectiveValue("AuthName"), - "user_file": v.vhost.GetDirectiveValue("AuthUserFile"), - } +func (v *ProxyVhost) ClearProxies() error { + vhostDir := filepath.Join(v.configDir, "vhost") + return clearProxyFiles(vhostDir) } -func (v *Vhost) SetBasicAuth(auth map[string]string) error { - if auth == nil || len(auth) == 0 { - // 清除基本认证配置 - v.vhost.RemoveDirective("AuthType") - v.vhost.RemoveDirective("AuthName") - v.vhost.RemoveDirective("AuthUserFile") - v.vhost.RemoveDirective("Require") - return nil - } - - realm := auth["realm"] - userFile := auth["user_file"] - - if realm == "" { - realm = "Restricted" - } - - v.vhost.SetDirective("AuthType", "Basic") - v.vhost.SetDirective("AuthName", fmt.Sprintf(`"%s"`, realm)) - v.vhost.SetDirective("AuthUserFile", userFile) - v.vhost.SetDirective("Require", "valid-user") - - return nil +func (v *ProxyVhost) Upstreams() map[string]types.Upstream { + globalDir := filepath.Join(v.configDir, "global") + upstreams, _ := parseBalancerFiles(globalDir) + return upstreams +} + +func (v *ProxyVhost) SetUpstreams(upstreams map[string]types.Upstream) error { + globalDir := filepath.Join(v.configDir, "global") + return writeBalancerFiles(globalDir, upstreams) +} + +func (v *ProxyVhost) ClearUpstreams() error { + globalDir := filepath.Join(v.configDir, "global") + return clearBalancerFiles(globalDir) } diff --git a/pkg/webserver/apache/vhost_test.go b/pkg/webserver/apache/vhost_test.go index c812e516..ff877871 100644 --- a/pkg/webserver/apache/vhost_test.go +++ b/pkg/webserver/apache/vhost_test.go @@ -13,7 +13,7 @@ import ( type VhostTestSuite struct { suite.Suite - vhost *Vhost + vhost *PHPVhost configDir string } @@ -27,11 +27,11 @@ func (s *VhostTestSuite) SetupTest() { s.Require().NoError(err) s.configDir = configDir - // 创建 server.d 目录 - err = os.MkdirAll(filepath.Join(configDir, "server.d"), 0755) + // 创建 vhost 目录 + err = os.MkdirAll(filepath.Join(configDir, "vhost"), 0755) s.Require().NoError(err) - vhost, err := NewVhost(configDir) + vhost, err := NewPHPVhost(configDir) s.Require().NoError(err) s.Require().NotNil(vhost) s.vhost = vhost @@ -45,13 +45,13 @@ func (s *VhostTestSuite) TearDownTest() { } func (s *VhostTestSuite) TestNewVhost() { - s.Equal(s.configDir, s.vhost.configDir) - s.NotNil(s.vhost.config) - s.NotNil(s.vhost.vhost) + s.Equal(s.configDir, s.vhost.baseVhost.configDir) + s.NotNil(s.vhost.baseVhost.config) + s.NotNil(s.vhost.baseVhost.vhost) } func (s *VhostTestSuite) TestEnable() { - // 默认应该是启用状态(没有 00-disable.conf) + // 默认应该是启用状态(没有 000-disable.conf) s.True(s.vhost.Enable()) // 禁用网站 @@ -60,7 +60,7 @@ func (s *VhostTestSuite) TestEnable() { s.False(s.vhost.Enable()) // 验证禁用文件存在 - disableFile := filepath.Join(s.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(s.configDir, "vhost", DisableConfName) _, err = os.Stat(disableFile) s.NoError(err) @@ -80,7 +80,7 @@ func (s *VhostTestSuite) TestDisableConfigContent() { s.NoError(err) // 读取禁用配置内容 - disableFile := filepath.Join(s.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(s.configDir, "vhost", DisableConfName) content, err := os.ReadFile(disableFile) s.NoError(err) @@ -381,7 +381,280 @@ func (s *VhostTestSuite) TestPHPFilesMatchBlock() { } func (s *VhostTestSuite) TestDefaultVhostConfIncludesServerD() { - // 验证默认配置包含 server.d 的 include - s.Contains(DefaultVhostConf, "server.d") + // 验证默认配置包含 vhost 的 include + s.Contains(DefaultVhostConf, "vhost") s.Contains(DefaultVhostConf, "IncludeOptional") } + +func (s *VhostTestSuite) TestRedirects() { + // 初始应该没有重定向 + s.Empty(s.vhost.Redirects()) + + // 设置重定向 + redirects := []types.Redirect{ + { + Type: types.RedirectTypeURL, + From: "/old", + To: "/new", + StatusCode: 301, + }, + { + Type: types.RedirectTypeHost, + From: "old.example.com", + To: "https://new.example.com", + KeepURI: true, + StatusCode: 308, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 验证重定向文件已创建 + vhostDir := filepath.Join(s.configDir, "vhost") + entries, err := os.ReadDir(vhostDir) + s.NoError(err) + + redirectCount := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "1") && strings.HasSuffix(entry.Name(), "-redirect.conf") { + redirectCount++ + } + } + s.Equal(2, redirectCount) + + // 验证可以读取回来 + got := s.vhost.Redirects() + s.Len(got, 2) +} + +func (s *VhostTestSuite) TestRedirectURL() { + redirects := []types.Redirect{ + { + Type: types.RedirectTypeURL, + From: "/old-page", + To: "/new-page", + StatusCode: 301, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "Redirect 301") + s.Contains(string(content), "/old-page") + s.Contains(string(content), "/new-page") +} + +func (s *VhostTestSuite) TestRedirectHost() { + redirects := []types.Redirect{ + { + Type: types.RedirectTypeHost, + From: "old.example.com", + To: "https://new.example.com", + KeepURI: true, + StatusCode: 308, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "RewriteEngine") + s.Contains(string(content), "RewriteCond") + s.Contains(string(content), "old.example.com") + s.Contains(string(content), "R=308") +} + +func (s *VhostTestSuite) TestRedirect404() { + redirects := []types.Redirect{ + { + Type: types.RedirectType404, + To: "/custom-404.html", + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "ErrorDocument 404") + s.Contains(string(content), "/custom-404.html") +} + +// ProxyVhost 测试套件 +type ProxyVhostTestSuite struct { + suite.Suite + vhost *ProxyVhost + configDir string +} + +func TestProxyVhostTestSuite(t *testing.T) { + suite.Run(t, &ProxyVhostTestSuite{}) +} + +func (s *ProxyVhostTestSuite) SetupTest() { + configDir, err := os.MkdirTemp("", "apache-proxy-test-*") + s.Require().NoError(err) + s.configDir = configDir + + // 创建 vhost 和 global 目录 + s.NoError(os.MkdirAll(filepath.Join(configDir, "vhost"), 0755)) + s.NoError(os.MkdirAll(filepath.Join(configDir, "global"), 0755)) + + vhost, err := NewProxyVhost(configDir) + s.Require().NoError(err) + s.vhost = vhost +} + +func (s *ProxyVhostTestSuite) TearDownTest() { + if s.configDir != "" { + s.NoError(os.RemoveAll(s.configDir)) + } +} + +func (s *ProxyVhostTestSuite) TestProxies() { + // 初始应该没有代理配置 + s.Empty(s.vhost.Proxies()) + + // 设置代理配置 + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend:8080/", + Host: "example.com", + }, + { + Location: "/api", + Pass: "http://api-backend:8080/", + Buffering: true, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 验证代理文件已创建 + vhostDir := filepath.Join(s.configDir, "vhost") + entries, err := os.ReadDir(vhostDir) + s.NoError(err) + + proxyCount := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "2") && strings.HasSuffix(entry.Name(), "-proxy.conf") { + proxyCount++ + } + } + s.Equal(2, proxyCount) + + // 验证可以读取回来 + got := s.vhost.Proxies() + s.Len(got, 2) +} + +func (s *ProxyVhostTestSuite) TestProxyConfig() { + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend:8080/", + Host: "example.com", + Buffering: true, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "200-proxy.conf")) + s.NoError(err) + + s.Contains(string(content), "ProxyPass /") + s.Contains(string(content), "ProxyPassReverse") + s.Contains(string(content), "http://backend:8080/") + s.Contains(string(content), "RequestHeader set Host") + s.Contains(string(content), "example.com") +} + +func (s *ProxyVhostTestSuite) TestClearProxies() { + proxies := []types.Proxy{ + {Location: "/", Pass: "http://backend/"}, + } + s.NoError(s.vhost.SetProxies(proxies)) + s.Len(s.vhost.Proxies(), 1) + + s.NoError(s.vhost.ClearProxies()) + s.Empty(s.vhost.Proxies()) +} + +func (s *ProxyVhostTestSuite) TestUpstreams() { + // 初始应该没有上游服务器配置 + s.Empty(s.vhost.Upstreams()) + + // 设置上游服务器(Apache 使用 balancer) + upstreams := map[string]types.Upstream{ + "backend": { + Servers: map[string]string{ + "http://127.0.0.1:8080": "loadfactor=5", + "http://127.0.0.1:8081": "loadfactor=3", + }, + Algo: "least_conn", + Keepalive: 32, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 验证 balancer 文件已创建 + globalDir := filepath.Join(s.configDir, "global") + entries, err := os.ReadDir(globalDir) + s.NoError(err) + s.NotEmpty(entries) + + // 验证可以读取回来 + got := s.vhost.Upstreams() + s.Len(got, 1) + s.Contains(got, "backend") +} + +func (s *ProxyVhostTestSuite) TestBalancerConfig() { + upstreams := map[string]types.Upstream{ + "mybackend": { + Servers: map[string]string{ + "http://127.0.0.1:8080": "loadfactor=5", + }, + Algo: "least_conn", + Keepalive: 16, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 读取配置文件内容 + globalDir := filepath.Join(s.configDir, "global") + entries, err := os.ReadDir(globalDir) + s.NoError(err) + s.Require().NotEmpty(entries) + + content, err := os.ReadFile(filepath.Join(globalDir, entries[0].Name())) + s.NoError(err) + + 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 +} + +func (s *ProxyVhostTestSuite) TestClearUpstreams() { + upstreams := map[string]types.Upstream{ + "backend": { + Servers: map[string]string{"http://127.0.0.1:8080": ""}, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + s.Len(s.vhost.Upstreams(), 1) + + s.NoError(s.vhost.ClearUpstreams()) + s.Empty(s.vhost.Upstreams()) +} diff --git a/pkg/webserver/nginx/data.go b/pkg/webserver/nginx/data.go index 255e59d5..893d0f6b 100644 --- a/pkg/webserver/nginx/data.go +++ b/pkg/webserver/nginx/data.go @@ -1,7 +1,7 @@ package nginx // DisableConfName 禁用配置文件名 -const DisableConfName = "00-disable.conf" +const DisableConfName = "000-disable.conf" // DisableConfContent 禁用配置内容 const DisableConfContent = `# 网站已停止 @@ -10,7 +10,16 @@ location / { } ` -const DefaultConf = `include /opt/ace/sites/default/config/http.d/*.conf; +// 配置文件序号范围 +const ( + RedirectStartNum = 100 // 重定向配置起始序号 (100-199) + RedirectEndNum = 199 + ProxyStartNum = 200 // 代理配置起始序号 (200-299) + ProxyEndNum = 299 + UpstreamStartNum = 100 // 上游服务器配置起始序号 +) + +const DefaultConf = `include /opt/ace/sites/default/config/global/*.conf; server { listen 80; server_name localhost; @@ -19,7 +28,7 @@ server { # error page error_page 404 /404.html; # custom configs - include /opt/ace/sites/default/config/server.d/*.conf; + include /opt/ace/sites/default/config/vhost/*.conf; # browser cache location ~ .*\.(bmp|jpg|jpeg|png|gif|svg|ico|tiff|webp|avif|heif|heic|jxl)$ { expires 30d; diff --git a/pkg/webserver/nginx/parser.go b/pkg/webserver/nginx/parser.go index 67e567a2..71e69067 100644 --- a/pkg/webserver/nginx/parser.go +++ b/pkg/webserver/nginx/parser.go @@ -214,10 +214,6 @@ func (p *Parser) parameters2Slices(parameters []config.Parameter) []string { // Save 保存配置到文件 func (p *Parser) Save() error { - if p.cfgPath == "" { - return fmt.Errorf("config file path is empty, cannot save") - } - content := p.Dump() if err := os.WriteFile(p.cfgPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to save config file: %w", err) diff --git a/pkg/webserver/nginx/parser_test.go b/pkg/webserver/nginx/parser_test.go index 3a18bd75..b119cdc5 100644 --- a/pkg/webserver/nginx/parser_test.go +++ b/pkg/webserver/nginx/parser_test.go @@ -96,7 +96,7 @@ func (s *NginxTestSuite) TestIncludes() { s.NoError(err) includes, comments, err := parser.GetIncludes() s.NoError(err) - s.Equal([]string{"/opt/ace/sites/default/config/server.d/*.conf"}, includes) + s.Equal([]string{"/opt/ace/sites/default/config/vhost/*.conf"}, includes) s.Equal([][]string{{"# custom configs"}}, comments) s.NoError(parser.SetIncludes([]string{"/www/server/vhost/rewrite/default.conf"}, nil)) includes, comments, err = parser.GetIncludes() diff --git a/pkg/webserver/nginx/proxy.go b/pkg/webserver/nginx/proxy.go new file mode 100644 index 00000000..926b7080 --- /dev/null +++ b/pkg/webserver/nginx/proxy.go @@ -0,0 +1,279 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/acepanel/panel/pkg/webserver/types" +) + +// proxyFilePattern 匹配代理配置文件名 (200-299) +var proxyFilePattern = regexp.MustCompile(`^(\d{3})-proxy\.conf$`) + +// parseProxyFiles 从 vhost 目录解析所有代理配置 +func parseProxyFiles(vhostDir string) ([]types.Proxy, error) { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var proxies []types.Proxy + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := proxyFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num < ProxyStartNum || num > ProxyEndNum { + continue + } + + filePath := filepath.Join(vhostDir, entry.Name()) + proxy, err := parseProxyFile(filePath) + if err != nil { + continue // 跳过解析失败的文件 + } + if proxy != nil { + proxies = append(proxies, *proxy) + } + } + + return proxies, nil +} + +// parseProxyFile 解析单个代理配置文件 +func parseProxyFile(filePath string) (*types.Proxy, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + + // 解析 location 块 + // location / { + // proxy_pass http://backend; + // ... + // } + locationPattern := regexp.MustCompile(`location\s+([^{]+)\{([^}]+(?:\{[^}]*}[^}]*)*)}`) + matches := locationPattern.FindStringSubmatch(contentStr) + if matches == nil { + return nil, nil + } + + proxy := &types.Proxy{ + Location: strings.TrimSpace(matches[1]), + Replaces: make(map[string]string), + } + + blockContent := matches[2] + + // 解析 proxy_pass + passPattern := regexp.MustCompile(`proxy_pass\s+([^;]+);`) + if pm := passPattern.FindStringSubmatch(blockContent); pm != nil { + proxy.Pass = strings.TrimSpace(pm[1]) + } + + // 解析 proxy_set_header Host + hostPattern := regexp.MustCompile(`proxy_set_header\s+Host\s+([^;]+);`) + if hm := hostPattern.FindStringSubmatch(blockContent); hm != nil { + host := strings.TrimSpace(hm[1]) + // 移除引号 + host = strings.Trim(host, `"'`) + if host != "$host" && host != "$http_host" { + proxy.Host = host + } + } + + // 解析 proxy_ssl_name (SNI) + sniPattern := regexp.MustCompile(`proxy_ssl_name\s+([^;]+);`) + if sm := sniPattern.FindStringSubmatch(blockContent); sm != nil { + proxy.SNI = strings.TrimSpace(sm[1]) + } + + // 解析 proxy_buffering + bufferingPattern := regexp.MustCompile(`proxy_buffering\s+(on|off);`) + if bm := bufferingPattern.FindStringSubmatch(blockContent); bm != nil { + proxy.Buffering = bm[1] == "on" + } + + // 解析 proxy_cache + cachePattern := regexp.MustCompile(`proxy_cache\s+(\S+);`) + if cm := cachePattern.FindStringSubmatch(blockContent); cm != nil && cm[1] != "off" { + proxy.Cache = true + } + + // 解析 resolver + resolverPattern := regexp.MustCompile(`resolver\s+([^;]+);`) + if rm := resolverPattern.FindStringSubmatch(blockContent); rm != nil { + parts := strings.Fields(rm[1]) + proxy.Resolver = parts + proxy.AutoRefresh = true // 有 resolver 通常意味着需要自动刷新 + } + + // 解析 resolver_timeout + resolverTimeoutPattern := regexp.MustCompile(`resolver_timeout\s+(\d+)([smh]?);`) + if rtm := resolverTimeoutPattern.FindStringSubmatch(blockContent); rtm != nil { + value, _ := strconv.Atoi(rtm[1]) + unit := rtm[2] + switch unit { + case "m": + proxy.ResolverTimeout = time.Duration(value) * time.Minute + case "h": + proxy.ResolverTimeout = time.Duration(value) * time.Hour + default: + proxy.ResolverTimeout = time.Duration(value) * time.Second + } + } + + // 解析 sub_filter (响应内容替换) + subFilterPattern := regexp.MustCompile(`sub_filter\s+"([^"]+)"\s+"([^"]*)";`) + subFilterMatches := subFilterPattern.FindAllStringSubmatch(blockContent, -1) + for _, sfm := range subFilterMatches { + proxy.Replaces[sfm[1]] = sfm[2] + } + + return proxy, nil +} + +// writeProxyFiles 将代理配置写入文件 +func writeProxyFiles(vhostDir string, proxies []types.Proxy) error { + // 删除现有的代理配置文件 (200-299) + if err := clearProxyFiles(vhostDir); err != nil { + return err + } + + // 写入新的配置文件 + for i, proxy := range proxies { + num := ProxyStartNum + i + if num > ProxyEndNum { + return fmt.Errorf("proxy rules exceed limit (%d)", ProxyEndNum-ProxyStartNum+1) + } + + fileName := fmt.Sprintf("%03d-proxy.conf", num) + filePath := filepath.Join(vhostDir, fileName) + + content := generateProxyConfig(proxy) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write proxy config: %w", err) + } + } + + return nil +} + +// clearProxyFiles 清除所有代理配置文件 +func clearProxyFiles(vhostDir string) error { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := proxyFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num >= ProxyStartNum && num <= ProxyEndNum { + filePath := filepath.Join(vhostDir, entry.Name()) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete proxy config: %w", err) + } + } + } + + return nil +} + +// generateProxyConfig 生成代理配置内容 +func generateProxyConfig(proxy types.Proxy) string { + var sb strings.Builder + + location := proxy.Location + if location == "" { + location = "/" + } + + sb.WriteString(fmt.Sprintf("# Reverse proxy: %s -> %s\n", location, proxy.Pass)) + sb.WriteString(fmt.Sprintf("location %s {\n", location)) + + // resolver 配置(如果启用自动刷新) + if proxy.AutoRefresh && len(proxy.Resolver) > 0 { + sb.WriteString(fmt.Sprintf(" resolver %s;\n", strings.Join(proxy.Resolver, " "))) + if proxy.ResolverTimeout > 0 { + sb.WriteString(fmt.Sprintf(" resolver_timeout %ds;\n", int(proxy.ResolverTimeout.Seconds()))) + } + // 使用变量实现动态解析 + sb.WriteString(fmt.Sprintf(" set $backend \"%s\";\n", proxy.Pass)) + sb.WriteString(" proxy_pass $backend;\n") + } else { + sb.WriteString(fmt.Sprintf(" proxy_pass %s;\n", proxy.Pass)) + } + + // Host 头 + if proxy.Host != "" { + sb.WriteString(fmt.Sprintf(" proxy_set_header Host \"%s\";\n", proxy.Host)) + } else { + sb.WriteString(" proxy_set_header Host $host;\n") + } + + // 标准代理头 + sb.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") + sb.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + sb.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") + + // SNI 配置 + if proxy.SNI != "" { + sb.WriteString(" proxy_ssl_server_name on;\n") + sb.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", proxy.SNI)) + } + + // Buffering 配置 + if proxy.Buffering { + sb.WriteString(" proxy_buffering on;\n") + } else { + sb.WriteString(" proxy_buffering off;\n") + } + + // Cache 配置 + if proxy.Cache { + sb.WriteString(" proxy_cache proxy_cache;\n") + sb.WriteString(" proxy_cache_valid 200 302 10m;\n") + sb.WriteString(" proxy_cache_valid 404 1m;\n") + } + + // 响应内容替换 + if len(proxy.Replaces) > 0 { + sb.WriteString(" proxy_set_header Accept-Encoding \"\";\n") + sb.WriteString(" sub_filter_once off;\n") + for from, to := range proxy.Replaces { + sb.WriteString(fmt.Sprintf(" sub_filter \"%s\" \"%s\";\n", from, to)) + } + } + + sb.WriteString("}\n") + + return sb.String() +} diff --git a/pkg/webserver/nginx/redirect.go b/pkg/webserver/nginx/redirect.go new file mode 100644 index 00000000..9b2b6268 --- /dev/null +++ b/pkg/webserver/nginx/redirect.go @@ -0,0 +1,211 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/acepanel/panel/pkg/webserver/types" +) + +// redirectFilePattern 匹配重定向配置文件名 (100-199) +var redirectFilePattern = regexp.MustCompile(`^(\d{3})-redirect\.conf$`) + +// parseRedirectFiles 从 vhost 目录解析所有重定向配置 +func parseRedirectFiles(vhostDir string) ([]types.Redirect, error) { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var redirects []types.Redirect + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := redirectFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num < RedirectStartNum || num > RedirectEndNum { + continue + } + + filePath := filepath.Join(vhostDir, entry.Name()) + redirect, err := parseRedirectFile(filePath) + if err != nil { + continue // 跳过解析失败的文件 + } + if redirect != nil { + redirects = append(redirects, *redirect) + } + } + + return redirects, nil +} + +// parseRedirectFile 解析单个重定向配置文件 +func parseRedirectFile(filePath string) (*types.Redirect, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + + // 解析 URL 重定向: location = /old { return 308 /new; } + urlPattern := regexp.MustCompile(`location\s*=\s*(\S+)\s*\{[^}]*return\s+(\d+)\s+([^;]+);`) + if matches := urlPattern.FindStringSubmatch(contentStr); matches != nil { + statusCode, _ := strconv.Atoi(matches[2]) + return &types.Redirect{ + Type: types.RedirectTypeURL, + From: matches[1], + To: strings.TrimSpace(matches[3]), + KeepURI: strings.Contains(matches[3], "$request_uri"), + StatusCode: statusCode, + }, nil + } + + // 解析 Host 重定向: if ($host = "old.example.com") { return 308 https://new.example.com$request_uri; } + 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]) + return &types.Redirect{ + Type: types.RedirectTypeHost, + From: matches[1], + To: strings.TrimSpace(matches[3]), + KeepURI: strings.Contains(matches[3], "$request_uri"), + StatusCode: statusCode, + }, nil + } + + // 解析 404 重定向: error_page 404 = @redirect_404; location @redirect_404 { return 308 /custom; } + 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]) + return &types.Redirect{ + Type: types.RedirectType404, + From: "", + To: strings.TrimSpace(matches[2]), + KeepURI: strings.Contains(matches[2], "$request_uri"), + StatusCode: statusCode, + }, nil + } + + return nil, nil +} + +// writeRedirectFiles 将重定向配置写入文件 +func writeRedirectFiles(vhostDir string, redirects []types.Redirect) error { + // 删除现有的重定向配置文件 (100-199) + if err := clearRedirectFiles(vhostDir); err != nil { + return err + } + + // 写入新的配置文件 + for i, redirect := range redirects { + num := RedirectStartNum + i + if num > RedirectEndNum { + return fmt.Errorf("redirect rules exceed limit (%d)", RedirectEndNum-RedirectStartNum+1) + } + + fileName := fmt.Sprintf("%03d-redirect.conf", num) + filePath := filepath.Join(vhostDir, fileName) + + content := generateRedirectConfig(redirect) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write redirect config: %w", err) + } + } + + return nil +} + +// clearRedirectFiles 清除所有重定向配置文件 +func clearRedirectFiles(vhostDir string) error { + entries, err := os.ReadDir(vhostDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := redirectFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num >= RedirectStartNum && num <= RedirectEndNum { + filePath := filepath.Join(vhostDir, entry.Name()) + if err = os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete redirect config: %w", err) + } + } + } + + return nil +} + +// generateRedirectConfig 生成重定向配置内容 +func generateRedirectConfig(redirect types.Redirect) string { + statusCode := redirect.StatusCode + if statusCode == 0 { + statusCode = 308 // 默认使用 308 永久重定向 + } + + var sb strings.Builder + + switch redirect.Type { + case types.RedirectTypeURL: + // URL 重定向 + sb.WriteString(fmt.Sprintf("# URL redirect: %s -> %s\n", redirect.From, redirect.To)) + sb.WriteString(fmt.Sprintf("location = %s {\n", redirect.From)) + if redirect.KeepURI { + sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To)) + } else { + sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To)) + } + sb.WriteString("}\n") + + case types.RedirectTypeHost: + // Host 重定向 + sb.WriteString(fmt.Sprintf("# Host redirect: %s -> %s\n", redirect.From, redirect.To)) + sb.WriteString(fmt.Sprintf("if ($host = \"%s\") {\n", redirect.From)) + if redirect.KeepURI { + sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To)) + } else { + sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To)) + } + sb.WriteString("}\n") + + case types.RedirectType404: + // 404 重定向 + sb.WriteString(fmt.Sprintf("# 404 redirect -> %s\n", redirect.To)) + sb.WriteString("error_page 404 = @redirect_404;\n") + sb.WriteString("location @redirect_404 {\n") + if redirect.KeepURI { + sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To)) + } else { + sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To)) + } + sb.WriteString("}\n") + } + + return sb.String() +} diff --git a/pkg/webserver/nginx/testdata/http.conf b/pkg/webserver/nginx/testdata/http.conf index fee718ff..58e9739e 100644 --- a/pkg/webserver/nginx/testdata/http.conf +++ b/pkg/webserver/nginx/testdata/http.conf @@ -1,4 +1,4 @@ -include /opt/ace/sites/default/config/http.d/*.conf; +include /opt/ace/sites/default/config/global/*.conf; server { listen 80; server_name localhost; @@ -7,7 +7,7 @@ server { # error page error_page 404 /404.html; # custom configs - include /opt/ace/sites/default/config/server.d/*.conf; + include /opt/ace/sites/default/config/vhost/*.conf; # browser cache location ~ .*\.(bmp|jpg|jpeg|png|gif|svg|ico|tiff|webp|avif|heif|heic|jxl)$ { expires 30d; diff --git a/pkg/webserver/nginx/testdata/https.conf b/pkg/webserver/nginx/testdata/https.conf index c73828ba..db456a58 100644 --- a/pkg/webserver/nginx/testdata/https.conf +++ b/pkg/webserver/nginx/testdata/https.conf @@ -1,4 +1,4 @@ -include /opt/ace/sites/default/config/http.d/*.conf; +include /opt/ace/sites/default/config/global/*.conf; server { listen 80; server_name localhost; @@ -15,7 +15,7 @@ server { # error page error_page 404 /404.html; # custom configs - include /opt/ace/sites/default/config/server.d/*.conf; + include /opt/ace/sites/default/config/vhost/*.conf; # browser cache location ~ .*\.(bmp|jpg|jpeg|png|gif|svg|ico|tiff|webp|avif|heif|heic|jxl)$ { expires 30d; diff --git a/pkg/webserver/nginx/upstream.go b/pkg/webserver/nginx/upstream.go new file mode 100644 index 00000000..1e95f726 --- /dev/null +++ b/pkg/webserver/nginx/upstream.go @@ -0,0 +1,202 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/acepanel/panel/pkg/webserver/types" +) + +// upstreamFilePattern 匹配 upstream 配置文件名 (100-XXX-name.conf) +var upstreamFilePattern = regexp.MustCompile(`^(\d{3})-(.+)\.conf$`) + +// parseUpstreamFiles 从 global 目录解析所有 upstream 配置 +func parseUpstreamFiles(globalDir string) (map[string]types.Upstream, error) { + entries, err := os.ReadDir(globalDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + upstreams := make(map[string]types.Upstream) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := upstreamFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num < UpstreamStartNum { + continue + } + + name := matches[2] + filePath := filepath.Join(globalDir, entry.Name()) + upstream, err := parseUpstreamFile(filePath, name) + if err != nil { + continue // 跳过解析失败的文件 + } + if upstream != nil { + upstreams[name] = *upstream + } + } + + return upstreams, nil +} + +// parseUpstreamFile 解析单个 upstream 配置文件 +func parseUpstreamFile(filePath string, expectedName string) (*types.Upstream, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + + // 解析 upstream 块 + // upstream backend { + // least_conn; + // server 127.0.0.1:8080 weight=5; + // keepalive 32; + // } + upstreamPattern := regexp.MustCompile(`upstream\s+(\S+)\s*\{([^}]+)}`) + matches := upstreamPattern.FindStringSubmatch(contentStr) + if matches == nil { + return nil, nil + } + + name := matches[1] + if expectedName != "" && name != expectedName { + return nil, nil + } + + blockContent := matches[2] + upstream := &types.Upstream{ + Servers: make(map[string]string), + } + + // 解析负载均衡算法 + algoPatterns := []string{"least_conn", "ip_hash", "hash", "random"} + for _, algo := range algoPatterns { + if regexp.MustCompile(`\b` + algo + `\b`).MatchString(blockContent) { + upstream.Algo = algo + break + } + } + + // 解析 server 指令 + serverPattern := regexp.MustCompile(`server\s+(\S+)(?:\s+([^;]+))?;`) + serverMatches := serverPattern.FindAllStringSubmatch(blockContent, -1) + for _, sm := range serverMatches { + addr := sm[1] + options := "" + if len(sm) > 2 { + options = strings.TrimSpace(sm[2]) + } + upstream.Servers[addr] = options + } + + // 解析 keepalive 指令 + keepalivePattern := regexp.MustCompile(`keepalive\s+(\d+);`) + if km := keepalivePattern.FindStringSubmatch(blockContent); km != nil { + upstream.Keepalive, _ = strconv.Atoi(km[1]) + } + + return upstream, nil +} + +// writeUpstreamFiles 将 upstream 配置写入文件 +func writeUpstreamFiles(globalDir string, upstreams map[string]types.Upstream) error { + // 删除现有的 upstream 配置文件 + if err := clearUpstreamFiles(globalDir); err != nil { + return err + } + + // 写入新的配置文件 + num := UpstreamStartNum + for name, upstream := range upstreams { + fileName := fmt.Sprintf("%03d-%s.conf", num, name) + filePath := filepath.Join(globalDir, fileName) + + content := generateUpstreamConfig(name, upstream) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write upstream config: %w", err) + } + num++ + } + + return nil +} + +// clearUpstreamFiles 清除所有 upstream 配置文件 +func clearUpstreamFiles(globalDir string) error { + entries, err := os.ReadDir(globalDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + matches := upstreamFilePattern.FindStringSubmatch(entry.Name()) + if matches == nil { + continue + } + + num, _ := strconv.Atoi(matches[1]) + if num >= UpstreamStartNum { + filePath := filepath.Join(globalDir, entry.Name()) + if err = os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete upstream config: %w", err) + } + } + } + + return nil +} + +// generateUpstreamConfig 生成 upstream 配置内容 +func generateUpstreamConfig(name string, upstream types.Upstream) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("# Upstream: %s\n", name)) + sb.WriteString(fmt.Sprintf("upstream %s {\n", name)) + + // 负载均衡算法 + if upstream.Algo != "" { + sb.WriteString(fmt.Sprintf(" %s;\n", upstream.Algo)) + } + + // 服务器列表 + for addr, options := range upstream.Servers { + if options != "" { + sb.WriteString(fmt.Sprintf(" server %s %s;\n", addr, options)) + } else { + sb.WriteString(fmt.Sprintf(" server %s;\n", addr)) + } + } + + // keepalive 连接数 + if upstream.Keepalive > 0 { + sb.WriteString(fmt.Sprintf(" keepalive %d;\n", upstream.Keepalive)) + } + + sb.WriteString("}\n") + + return sb.String() +} diff --git a/pkg/webserver/nginx/vhost.go b/pkg/webserver/nginx/vhost.go index 41e89f07..798b3126 100644 --- a/pkg/webserver/nginx/vhost.go +++ b/pkg/webserver/nginx/vhost.go @@ -10,16 +10,34 @@ import ( "github.com/acepanel/panel/pkg/webserver/types" ) -// Vhost Nginx 虚拟主机实现 -type Vhost struct { +// StaticVhost 纯静态虚拟主机 +type StaticVhost struct { + *baseVhost +} + +// PHPVhost PHP 虚拟主机 +type PHPVhost struct { + *baseVhost +} + +// ProxyVhost 反向代理虚拟主机 +type ProxyVhost struct { + *baseVhost +} + +// baseVhost Nginx 虚拟主机基础实现 +type baseVhost struct { parser *Parser configDir string // 配置目录 } -// NewVhost 创建 Nginx 虚拟主机实例 -// configDir: 配置目录路径 -func NewVhost(configDir string) (*Vhost, error) { - v := &Vhost{ +// newBaseVhost 创建基础虚拟主机实例 +func newBaseVhost(configDir string) (*baseVhost, error) { + if configDir == "" { + return nil, fmt.Errorf("config directory is required") + } + + v := &baseVhost{ configDir: configDir, } @@ -27,14 +45,12 @@ func NewVhost(configDir string) (*Vhost, error) { var parser *Parser var err error - if v.configDir != "" { - // 从配置目录加载主配置文件 - configFile := filepath.Join(v.configDir, "nginx.conf") - if _, statErr := os.Stat(configFile); statErr == nil { - parser, err = NewParserFromFile(configFile) - if err != nil { - return nil, fmt.Errorf("failed to load nginx config: %w", err) - } + // 从配置目录加载主配置文件 + configFile := filepath.Join(v.configDir, "nginx.conf") + if _, statErr := os.Stat(configFile); statErr == nil { + parser, err = NewParserFromFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to load nginx config: %w", err) } } @@ -45,27 +61,49 @@ func NewVhost(configDir string) (*Vhost, error) { if err != nil { return nil, fmt.Errorf("failed to load default config: %w", err) } - // 如果有 configDir,设置配置文件路径 - if v.configDir != "" { - parser.SetConfigPath(filepath.Join(v.configDir, "nginx.conf")) - } + parser.SetConfigPath(filepath.Join(v.configDir, "nginx.conf")) } v.parser = parser return v, nil } -// ========== VhostCore 接口实现 ========== +// NewStaticVhost 创建纯静态虚拟主机实例 +func NewStaticVhost(configDir string) (*StaticVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &StaticVhost{baseVhost: base}, nil +} -func (v *Vhost) Enable() bool { +// NewPHPVhost 创建 PHP 虚拟主机实例 +func NewPHPVhost(configDir string) (*PHPVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &PHPVhost{baseVhost: base}, nil +} + +// NewProxyVhost 创建反向代理虚拟主机实例 +func NewProxyVhost(configDir string) (*ProxyVhost, error) { + base, err := newBaseVhost(configDir) + if err != nil { + return nil, err + } + return &ProxyVhost{baseVhost: base}, nil +} + +func (v *baseVhost) Enable() bool { // 检查禁用配置文件是否存在 - disableFile := filepath.Join(v.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(v.configDir, "vhost", DisableConfName) _, err := os.Stat(disableFile) return os.IsNotExist(err) } -func (v *Vhost) SetEnable(enable bool, _ ...string) error { - serverDir := filepath.Join(v.configDir, "server.d") +func (v *baseVhost) SetEnable(enable bool, _ ...string) error { + serverDir := filepath.Join(v.configDir, "vhost") disableFile := filepath.Join(serverDir, DisableConfName) if enable { @@ -77,12 +115,6 @@ func (v *Vhost) SetEnable(enable bool, _ ...string) error { } // 禁用:创建禁用配置文件 - // 确保目录存在 - if err := os.MkdirAll(serverDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // 写入禁用配置 if err := os.WriteFile(disableFile, []byte(DisableConfContent), 0644); err != nil { return fmt.Errorf("failed to write disable config: %w", err) } @@ -90,7 +122,7 @@ func (v *Vhost) SetEnable(enable bool, _ ...string) error { return nil } -func (v *Vhost) Listen() []types.Listen { +func (v *baseVhost) Listen() []types.Listen { listens, err := v.parser.GetListen() if err != nil { return nil @@ -132,7 +164,7 @@ func (v *Vhost) Listen() []types.Listen { return result } -func (v *Vhost) SetListen(listens []types.Listen) error { +func (v *baseVhost) SetListen(listens []types.Listen) error { // 将通用 Listen 转换为 Nginx 格式 var nginxListens [][]string for _, l := range listens { @@ -163,7 +195,7 @@ func (v *Vhost) SetListen(listens []types.Listen) error { return v.parser.SetListen(nginxListens) } -func (v *Vhost) ServerName() []string { +func (v *baseVhost) ServerName() []string { names, err := v.parser.GetServerName() if err != nil { return nil @@ -171,11 +203,11 @@ func (v *Vhost) ServerName() []string { return names } -func (v *Vhost) SetServerName(serverName []string) error { +func (v *baseVhost) SetServerName(serverName []string) error { return v.parser.SetServerName(serverName) } -func (v *Vhost) Index() []string { +func (v *baseVhost) Index() []string { index, err := v.parser.GetIndex() if err != nil { return nil @@ -183,11 +215,11 @@ func (v *Vhost) Index() []string { return index } -func (v *Vhost) SetIndex(index []string) error { +func (v *baseVhost) SetIndex(index []string) error { return v.parser.SetIndex(index) } -func (v *Vhost) Root() string { +func (v *baseVhost) Root() string { root, err := v.parser.GetRoot() if err != nil { return "" @@ -195,11 +227,11 @@ func (v *Vhost) Root() string { return root } -func (v *Vhost) SetRoot(root string) error { +func (v *baseVhost) SetRoot(root string) error { return v.parser.SetRoot(root) } -func (v *Vhost) Includes() []types.IncludeFile { +func (v *baseVhost) Includes() []types.IncludeFile { includes, comments, err := v.parser.GetIncludes() if err != nil { return nil @@ -219,7 +251,7 @@ func (v *Vhost) Includes() []types.IncludeFile { return result } -func (v *Vhost) SetIncludes(includes []types.IncludeFile) error { +func (v *baseVhost) SetIncludes(includes []types.IncludeFile) error { var paths []string var comments [][]string @@ -231,7 +263,7 @@ func (v *Vhost) SetIncludes(includes []types.IncludeFile) error { return v.parser.SetIncludes(paths, comments) } -func (v *Vhost) AccessLog() string { +func (v *baseVhost) AccessLog() string { log, err := v.parser.GetAccessLog() if err != nil { return "" @@ -239,11 +271,11 @@ func (v *Vhost) AccessLog() string { return log } -func (v *Vhost) SetAccessLog(accessLog string) error { +func (v *baseVhost) SetAccessLog(accessLog string) error { return v.parser.SetAccessLog(accessLog) } -func (v *Vhost) ErrorLog() string { +func (v *baseVhost) ErrorLog() string { log, err := v.parser.GetErrorLog() if err != nil { return "" @@ -251,48 +283,24 @@ func (v *Vhost) ErrorLog() string { return log } -func (v *Vhost) SetErrorLog(errorLog string) error { +func (v *baseVhost) SetErrorLog(errorLog string) error { return v.parser.SetErrorLog(errorLog) } -func (v *Vhost) Save() error { +func (v *baseVhost) Save() error { return v.parser.Save() } -func (v *Vhost) Reload() error { - // 重载 Nginx 配置 - // 优先使用 openresty,如果不存在则使用 nginx - cmds := []string{ - "/opt/ace/apps/openresty/bin/openresty -s reload", - "/usr/sbin/nginx -s reload", - "nginx -s reload", +func (v *baseVhost) Reload() error { + parts := strings.Fields("systemctl reload openresty") + if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil { + return fmt.Errorf("failed to reload nginx config: %w", err) } - var lastErr error - for _, cmd := range cmds { - parts := strings.Fields(cmd) - if len(parts) < 2 { - continue - } - - // 检查命令是否存在 - if _, err := os.Stat(parts[0]); err == nil { - // 执行重载命令 - err := exec.Command(parts[0], parts[1:]...).Run() - if err == nil { - return nil - } - lastErr = err - } - } - - if lastErr != nil { - return fmt.Errorf("failed to reload nginx config: %w", lastErr) - } - return fmt.Errorf("nginx or openresty command not found") + return nil } -func (v *Vhost) Reset() error { +func (v *baseVhost) Reset() error { // 重置配置为默认值 parser, err := NewParser("") if err != nil { @@ -308,13 +316,11 @@ func (v *Vhost) Reset() error { return nil } -// ========== VhostSSL 接口实现 ========== - -func (v *Vhost) HTTPS() bool { +func (v *baseVhost) HTTPS() bool { return v.parser.GetHTTPS() } -func (v *Vhost) SSLConfig() *types.SSLConfig { +func (v *baseVhost) SSLConfig() *types.SSLConfig { if !v.HTTPS() { return nil } @@ -329,7 +335,7 @@ func (v *Vhost) SSLConfig() *types.SSLConfig { } } -func (v *Vhost) SetSSLConfig(cfg *types.SSLConfig) error { +func (v *baseVhost) SetSSLConfig(cfg *types.SSLConfig) error { if cfg == nil { return fmt.Errorf("SSL config cannot be nil") } @@ -378,41 +384,11 @@ func (v *Vhost) SetSSLConfig(cfg *types.SSLConfig) error { return nil } -func (v *Vhost) ClearHTTPS() error { +func (v *baseVhost) ClearHTTPS() error { return v.parser.ClearHTTPS() } -// ========== VhostPHP 接口实现 ========== - -func (v *Vhost) PHP() int { - return v.parser.GetPHP() -} - -func (v *Vhost) SetPHP(version int) error { - // 先移除所有 PHP 相关的 include - includes := v.Includes() - var newIncludes []types.IncludeFile - for _, inc := range includes { - // 过滤掉 enable-php-*.conf - if !strings.HasPrefix(inc.Path, "enable-php-") || !strings.HasSuffix(inc.Path, ".conf") { - newIncludes = append(newIncludes, inc) - } - } - - // 如果版本不为 0,添加新的 PHP include - if version > 0 { - newIncludes = append(newIncludes, types.IncludeFile{ - Path: fmt.Sprintf("enable-php-%d.conf", version), - Comment: []string{fmt.Sprintf("# Enable PHP %d.%d", version/10, version%10)}, - }) - } - - return v.SetIncludes(newIncludes) -} - -// ========== VhostAdvanced 接口实现 ========== - -func (v *Vhost) RateLimit() *types.RateLimit { +func (v *baseVhost) RateLimit() *types.RateLimit { rate := v.parser.GetLimitRate() limitConn := v.parser.GetLimitConn() @@ -437,7 +413,7 @@ func (v *Vhost) RateLimit() *types.RateLimit { return rateLimit } -func (v *Vhost) SetRateLimit(limit *types.RateLimit) error { +func (v *baseVhost) SetRateLimit(limit *types.RateLimit) error { if limit == nil { // 清除限流配置 if err := v.parser.SetLimitRate(""); err != nil { @@ -460,7 +436,7 @@ func (v *Vhost) SetRateLimit(limit *types.RateLimit) error { return v.parser.SetLimitConn(limitConns) } -func (v *Vhost) BasicAuth() map[string]string { +func (v *baseVhost) BasicAuth() map[string]string { realm, userFile := v.parser.GetBasicAuth() if realm == "" || userFile == "" { return nil @@ -474,7 +450,7 @@ func (v *Vhost) BasicAuth() map[string]string { } } -func (v *Vhost) SetBasicAuth(auth map[string]string) error { +func (v *baseVhost) SetBasicAuth(auth map[string]string) error { if auth == nil || len(auth) == 0 { // 清除基本认证配置 return v.parser.SetBasicAuth("", "") @@ -489,3 +465,76 @@ func (v *Vhost) SetBasicAuth(auth map[string]string) error { return v.parser.SetBasicAuth(realm, userFile) } + +func (v *baseVhost) Redirects() []types.Redirect { + vhostDir := filepath.Join(v.configDir, "vhost") + redirects, _ := parseRedirectFiles(vhostDir) + return redirects +} + +func (v *baseVhost) SetRedirects(redirects []types.Redirect) error { + vhostDir := filepath.Join(v.configDir, "vhost") + return writeRedirectFiles(vhostDir, redirects) +} + +// ========== PHPVhost ========== + +func (v *PHPVhost) PHP() int { + return v.parser.GetPHP() +} + +func (v *PHPVhost) SetPHP(version int) error { + // 先移除所有 PHP 相关的 include + includes := v.Includes() + var newIncludes []types.IncludeFile + for _, inc := range includes { + // 过滤掉 enable-php-*.conf + if !strings.HasPrefix(inc.Path, "enable-php-") || !strings.HasSuffix(inc.Path, ".conf") { + newIncludes = append(newIncludes, inc) + } + } + + // 如果版本不为 0,添加新的 PHP include + if version > 0 { + newIncludes = append(newIncludes, types.IncludeFile{ + Path: fmt.Sprintf("enable-php-%d.conf", version), + Comment: []string{fmt.Sprintf("# Enable PHP %d.%d", version/10, version%10)}, + }) + } + + return v.SetIncludes(newIncludes) +} + +// ========== ProxyVhost ========== + +func (v *ProxyVhost) Proxies() []types.Proxy { + vhostDir := filepath.Join(v.configDir, "vhost") + proxies, _ := parseProxyFiles(vhostDir) + return proxies +} + +func (v *ProxyVhost) SetProxies(proxies []types.Proxy) error { + vhostDir := filepath.Join(v.configDir, "vhost") + return writeProxyFiles(vhostDir, proxies) +} + +func (v *ProxyVhost) ClearProxies() error { + vhostDir := filepath.Join(v.configDir, "vhost") + return clearProxyFiles(vhostDir) +} + +func (v *ProxyVhost) Upstreams() map[string]types.Upstream { + globalDir := filepath.Join(v.configDir, "global") + upstreams, _ := parseUpstreamFiles(globalDir) + return upstreams +} + +func (v *ProxyVhost) SetUpstreams(upstreams map[string]types.Upstream) error { + globalDir := filepath.Join(v.configDir, "global") + return writeUpstreamFiles(globalDir, upstreams) +} + +func (v *ProxyVhost) ClearUpstreams() error { + globalDir := filepath.Join(v.configDir, "global") + return clearUpstreamFiles(globalDir) +} diff --git a/pkg/webserver/nginx/vhost_test.go b/pkg/webserver/nginx/vhost_test.go index 7e24d5ba..7ebcdcc5 100644 --- a/pkg/webserver/nginx/vhost_test.go +++ b/pkg/webserver/nginx/vhost_test.go @@ -13,7 +13,7 @@ import ( type VhostTestSuite struct { suite.Suite - vhost *Vhost + vhost *PHPVhost configDir string } @@ -27,11 +27,11 @@ func (s *VhostTestSuite) SetupTest() { s.Require().NoError(err) s.configDir = configDir - // 创建 server.d 目录 - err = os.MkdirAll(filepath.Join(configDir, "server.d"), 0755) + // 创建 vhost 目录 + err = os.MkdirAll(filepath.Join(configDir, "vhost"), 0755) s.Require().NoError(err) - vhost, err := NewVhost(configDir) + vhost, err := NewPHPVhost(configDir) s.Require().NoError(err) s.Require().NotNil(vhost) s.vhost = vhost @@ -45,12 +45,12 @@ func (s *VhostTestSuite) TearDownTest() { } func (s *VhostTestSuite) TestNewVhost() { - s.Equal(s.configDir, s.vhost.configDir) - s.NotNil(s.vhost.parser) + s.Equal(s.configDir, s.vhost.baseVhost.configDir) + s.NotNil(s.vhost.baseVhost.parser) } func (s *VhostTestSuite) TestEnable() { - // 默认应该是启用状态(没有 00-disable.conf) + // 默认应该是启用状态(没有 000-disable.conf) s.True(s.vhost.Enable()) // 禁用网站 @@ -58,7 +58,7 @@ func (s *VhostTestSuite) TestEnable() { s.False(s.vhost.Enable()) // 验证禁用文件存在 - disableFile := filepath.Join(s.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(s.configDir, "vhost", DisableConfName) _, err := os.Stat(disableFile) s.NoError(err) @@ -76,7 +76,7 @@ func (s *VhostTestSuite) TestDisableConfigContent() { s.NoError(s.vhost.SetEnable(false)) // 读取禁用配置内容 - disableFile := filepath.Join(s.configDir, "server.d", DisableConfName) + disableFile := filepath.Join(s.configDir, "vhost", DisableConfName) content, err := os.ReadFile(disableFile) s.NoError(err) @@ -343,7 +343,319 @@ func (s *VhostTestSuite) TestAltSvc() { } func (s *VhostTestSuite) TestDefaultConfIncludesServerD() { - // 验证默认配置包含 server.d 的 include - s.Contains(DefaultConf, "server.d") + // 验证默认配置包含 vhost 的 include + s.Contains(DefaultConf, "vhost") s.Contains(DefaultConf, "include") } + +func (s *VhostTestSuite) TestRedirects() { + // 初始应该没有重定向 + s.Empty(s.vhost.Redirects()) + + // 设置重定向 + redirects := []types.Redirect{ + { + Type: types.RedirectTypeURL, + From: "/old", + To: "/new", + StatusCode: 301, + }, + { + Type: types.RedirectTypeHost, + From: "old.example.com", + To: "https://new.example.com", + KeepURI: true, + StatusCode: 308, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 验证重定向文件已创建 + vhostDir := filepath.Join(s.configDir, "vhost") + entries, err := os.ReadDir(vhostDir) + s.NoError(err) + + redirectCount := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "1") && strings.HasSuffix(entry.Name(), "-redirect.conf") { + redirectCount++ + } + } + s.Equal(2, redirectCount) + + // 验证可以读取回来 + got := s.vhost.Redirects() + s.Len(got, 2) +} + +func (s *VhostTestSuite) TestRedirectURL() { + redirects := []types.Redirect{ + { + Type: types.RedirectTypeURL, + From: "/old-page", + To: "/new-page", + StatusCode: 301, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "location = /old-page") + s.Contains(string(content), "return 301") + s.Contains(string(content), "/new-page") +} + +func (s *VhostTestSuite) TestRedirectHost() { + redirects := []types.Redirect{ + { + Type: types.RedirectTypeHost, + From: "old.example.com", + To: "https://new.example.com", + KeepURI: true, + StatusCode: 308, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "$host") + s.Contains(string(content), "old.example.com") + s.Contains(string(content), "return 308") + s.Contains(string(content), "$request_uri") +} + +func (s *VhostTestSuite) TestRedirect404() { + redirects := []types.Redirect{ + { + Type: types.RedirectType404, + To: "/custom-404.html", + StatusCode: 308, + }, + } + s.NoError(s.vhost.SetRedirects(redirects)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "100-redirect.conf")) + s.NoError(err) + + s.Contains(string(content), "error_page 404") + s.Contains(string(content), "@redirect_404") +} + +// ProxyVhost 测试套件 +type ProxyVhostTestSuite struct { + suite.Suite + vhost *ProxyVhost + configDir string +} + +func TestProxyVhostTestSuite(t *testing.T) { + suite.Run(t, &ProxyVhostTestSuite{}) +} + +func (s *ProxyVhostTestSuite) SetupTest() { + configDir, err := os.MkdirTemp("", "nginx-proxy-test-*") + s.Require().NoError(err) + s.configDir = configDir + + // 创建 vhost 和 global 目录 + s.NoError(os.MkdirAll(filepath.Join(configDir, "vhost"), 0755)) + s.NoError(os.MkdirAll(filepath.Join(configDir, "global"), 0755)) + + vhost, err := NewProxyVhost(configDir) + s.Require().NoError(err) + s.vhost = vhost +} + +func (s *ProxyVhostTestSuite) TearDownTest() { + if s.configDir != "" { + s.NoError(os.RemoveAll(s.configDir)) + } +} + +func (s *ProxyVhostTestSuite) TestProxies() { + // 初始应该没有代理配置 + s.Empty(s.vhost.Proxies()) + + // 设置代理配置 + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend", + Host: "example.com", + }, + { + Location: "/api", + Pass: "http://api-backend:8080", + Buffering: true, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 验证代理文件已创建 + vhostDir := filepath.Join(s.configDir, "vhost") + entries, err := os.ReadDir(vhostDir) + s.NoError(err) + + proxyCount := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "2") && strings.HasSuffix(entry.Name(), "-proxy.conf") { + proxyCount++ + } + } + s.Equal(2, proxyCount) + + // 验证可以读取回来 + got := s.vhost.Proxies() + s.Len(got, 2) +} + +func (s *ProxyVhostTestSuite) TestProxyConfig() { + proxies := []types.Proxy{ + { + Location: "/", + Pass: "http://backend", + Host: "example.com", + SNI: "example.com", + Buffering: true, + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 读取配置文件内容 + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "200-proxy.conf")) + s.NoError(err) + + s.Contains(string(content), "location /") + s.Contains(string(content), "proxy_pass http://backend") + s.Contains(string(content), "proxy_set_header Host") + s.Contains(string(content), "example.com") + s.Contains(string(content), "proxy_ssl_name") + s.Contains(string(content), "proxy_buffering on") +} + +func (s *ProxyVhostTestSuite) TestClearProxies() { + proxies := []types.Proxy{ + {Location: "/", Pass: "http://backend"}, + } + s.NoError(s.vhost.SetProxies(proxies)) + s.Len(s.vhost.Proxies(), 1) + + s.NoError(s.vhost.ClearProxies()) + s.Empty(s.vhost.Proxies()) +} + +func (s *ProxyVhostTestSuite) TestUpstreams() { + // 初始应该没有上游服务器配置 + s.Empty(s.vhost.Upstreams()) + + // 设置上游服务器 + upstreams := map[string]types.Upstream{ + "backend": { + Servers: map[string]string{ + "127.0.0.1:8080": "weight=5", + "127.0.0.1:8081": "weight=3", + }, + Algo: "least_conn", + Keepalive: 32, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 验证 upstream 文件已创建 + globalDir := filepath.Join(s.configDir, "global") + entries, err := os.ReadDir(globalDir) + s.NoError(err) + s.NotEmpty(entries) + + // 验证可以读取回来 + got := s.vhost.Upstreams() + s.Len(got, 1) + s.Contains(got, "backend") + s.Equal("least_conn", got["backend"].Algo) + s.Equal(32, got["backend"].Keepalive) +} + +func (s *ProxyVhostTestSuite) TestUpstreamConfig() { + upstreams := map[string]types.Upstream{ + "mybackend": { + Servers: map[string]string{ + "127.0.0.1:8080": "weight=5", + }, + Algo: "ip_hash", + Keepalive: 16, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 读取配置文件内容 + globalDir := filepath.Join(s.configDir, "global") + entries, err := os.ReadDir(globalDir) + s.NoError(err) + s.Require().NotEmpty(entries) + + content, err := os.ReadFile(filepath.Join(globalDir, entries[0].Name())) + s.NoError(err) + + s.Contains(string(content), "upstream mybackend") + s.Contains(string(content), "ip_hash") + s.Contains(string(content), "server 127.0.0.1:8080") + s.Contains(string(content), "weight=5") + s.Contains(string(content), "keepalive 16") +} + +func (s *ProxyVhostTestSuite) TestClearUpstreams() { + upstreams := map[string]types.Upstream{ + "backend": { + Servers: map[string]string{"127.0.0.1:8080": ""}, + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + s.Len(s.vhost.Upstreams(), 1) + + s.NoError(s.vhost.ClearUpstreams()) + s.Empty(s.vhost.Upstreams()) +} + +func (s *ProxyVhostTestSuite) TestProxyWithUpstream() { + // 先创建 upstream + upstreams := map[string]types.Upstream{ + "api-servers": { + Servers: map[string]string{ + "127.0.0.1:3000": "", + "127.0.0.1:3001": "", + }, + Algo: "least_conn", + }, + } + s.NoError(s.vhost.SetUpstreams(upstreams)) + + // 然后创建引用 upstream 的 proxy + proxies := []types.Proxy{ + { + Location: "/api", + Pass: "http://api-servers", + }, + } + s.NoError(s.vhost.SetProxies(proxies)) + + // 验证两者都存在 + s.Len(s.vhost.Upstreams(), 1) + s.Len(s.vhost.Proxies(), 1) + + // 验证 proxy 配置中引用了 upstream + vhostDir := filepath.Join(s.configDir, "vhost") + content, err := os.ReadFile(filepath.Join(vhostDir, "200-proxy.conf")) + s.NoError(err) + s.Contains(string(content), "http://api-servers") +} diff --git a/pkg/webserver/types/proxy.go b/pkg/webserver/types/proxy.go index 31c62f4d..9d2e73c9 100644 --- a/pkg/webserver/types/proxy.go +++ b/pkg/webserver/types/proxy.go @@ -4,6 +4,7 @@ import "time" // Proxy 反向代理配置 type Proxy struct { + Location string // 匹配路径,如: "/", "/api", "~ ^/api/v[0-9]+/" AutoRefresh bool // 是否自动刷新解析 Pass string // 代理地址,如: "http://example.com", "http://backend" Host string // 代理 Host,如: "example.com" @@ -17,7 +18,6 @@ type Proxy struct { // Upstream 上游服务器配置 type Upstream struct { - Name string // 上游名称,如: "backend" Servers map[string]string // 上游服务器及权重,如: map["server1"] = "weight=5" Algo string // 负载均衡算法,如: "least_conn", "ip_hash" Keepalive int // 保持连接数,如: 32 diff --git a/pkg/webserver/types/vhost.go b/pkg/webserver/types/vhost.go index f1f554b9..c5143c8d 100644 --- a/pkg/webserver/types/vhost.go +++ b/pkg/webserver/types/vhost.go @@ -1,24 +1,9 @@ package types -// VhostType 虚拟主机类型 -type VhostType string - -const ( - VhostTypeStatic VhostType = "static" - VhostTypePHP VhostType = "php" - VhostTypeProxy VhostType = "proxy" -) - -// Vhost 虚拟主机完整接口 +// Vhost 虚拟主机通用接口 type Vhost interface { - VhostCore - VhostSSL - VhostPHP - VhostAdvanced -} + // ========== 核心方法 ========== -// VhostCore 核心接口 -type VhostCore interface { // Enable 取启用状态 Enable() bool // SetEnable 设置启用状态及停止页路径 @@ -65,10 +50,9 @@ type VhostCore interface { Reload() error // Reset 重置配置为默认值 Reset() error -} -// VhostSSL SSL/TLS 相关接口 -type VhostSSL interface { + // ========== SSL/TLS 方法 ========== + // HTTPS 取 HTTPS 启用状态 HTTPS() bool // SSLConfig 取 SSL 配置 @@ -77,6 +61,38 @@ type VhostSSL interface { SetSSLConfig(cfg *SSLConfig) error // ClearHTTPS 清除 HTTPS 配置 ClearHTTPS() error + + // ========== 高级功能方法 ========== + + // RateLimit 取限流限速配置 + RateLimit() *RateLimit + // SetRateLimit 设置限流限速配置 + SetRateLimit(limit *RateLimit) error + + // BasicAuth 取基本认证配置 + BasicAuth() map[string]string + // SetBasicAuth 设置基本认证 + SetBasicAuth(auth map[string]string) error +} + +// StaticVhost 纯静态虚拟主机接口 +type StaticVhost interface { + Vhost + VhostRedirect +} + +// PHPVhost PHP 虚拟主机接口 +type PHPVhost interface { + Vhost + VhostPHP + VhostRedirect +} + +// ProxyVhost 反向代理虚拟主机接口 +type ProxyVhost interface { + Vhost + VhostRedirect + VhostProxyConfig } // VhostPHP PHP 相关接口 @@ -87,17 +103,29 @@ type VhostPHP interface { SetPHP(version int) error } -// VhostAdvanced 高级功能接口 -type VhostAdvanced interface { - // RateLimit 取限流限速配置 - RateLimit() *RateLimit - // SetRateLimit 设置限流限速配置 - SetRateLimit(limit *RateLimit) error +// VhostRedirect 重定向相关接口 +type VhostRedirect interface { + // Redirects 取所有重定向配置 + Redirects() []Redirect + // SetRedirects 设置重定向 + SetRedirects(redirects []Redirect) error +} - // BasicAuth 取基本认证配置 - BasicAuth() map[string]string - // SetBasicAuth 设置基本认证 - SetBasicAuth(auth map[string]string) error +// VhostProxyConfig 反向代理相关接口 +type VhostProxyConfig interface { + // Proxies 取所有反向代理配置 + Proxies() []Proxy + // SetProxies 设置反向代理配置 + SetProxies(proxies []Proxy) error + // ClearProxies 清除所有反向代理配置 + ClearProxies() error + + // Upstreams 取上游服务器配置 + Upstreams() map[string]Upstream + // SetUpstreams 设置上游服务器配置 + SetUpstreams(upstreams map[string]Upstream) error + // ClearUpstreams 清除所有上游服务器配置 + ClearUpstreams() error } // Listen 监听配置 diff --git a/pkg/webserver/webserver.go b/pkg/webserver/webserver.go index 72f54657..c5fb2c06 100644 --- a/pkg/webserver/webserver.go +++ b/pkg/webserver/webserver.go @@ -8,13 +8,37 @@ import ( "github.com/acepanel/panel/pkg/webserver/types" ) -// NewVhost 创建虚拟主机管理实例 -func NewVhost(serverType Type, configDir string) (types.Vhost, error) { +// NewStaticVhost 创建纯静态虚拟主机实例 +func NewStaticVhost(serverType Type, configDir string) (types.StaticVhost, error) { switch serverType { case TypeNginx: - return nginx.NewVhost(configDir) + return nginx.NewStaticVhost(configDir) case TypeApache: - return apache.NewVhost(configDir) + return apache.NewStaticVhost(configDir) + default: + return nil, fmt.Errorf("unsupported server type: %s", serverType) + } +} + +// NewPHPVhost 创建 PHP 虚拟主机实例 +func NewPHPVhost(serverType Type, configDir string) (types.PHPVhost, error) { + switch serverType { + case TypeNginx: + return nginx.NewPHPVhost(configDir) + case TypeApache: + return apache.NewPHPVhost(configDir) + default: + return nil, fmt.Errorf("unsupported server type: %s", serverType) + } +} + +// NewProxyVhost 创建反向代理虚拟主机实例 +func NewProxyVhost(serverType Type, configDir string) (types.ProxyVhost, error) { + switch serverType { + case TypeNginx: + return nginx.NewProxyVhost(configDir) + case TypeApache: + return apache.NewProxyVhost(configDir) default: return nil, fmt.Errorf("unsupported server type: %s", serverType) }