Files
panel/internal/data/website.go
T
耗子andClaude Fable 5.1 6158381b53 feat(openlitespeed): 服务器级真实 IP、站点 LSCache 开关与验证免重载
- OLS 设置页新增真实 IP 标签,写入 conf/panel/realip.conf:有可信代理列表时
  只信任列表来源,留空则信任所有来源
- 站点高级设置新增 LSCache 开关,写入 vhost 级 module cache 片段,缓存目录按站点隔离
- HTTP-01 投放与清理返回配置是否变化,求解器据此跳过空重载;OLS 探测运行中
  服务是否已按当前指纹应答,命中即不重载

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:10:00 +08:00

1550 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package data
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/cert"
"github.com/acepanel/panel/v3/pkg/embed"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/punycode"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/tools"
"github.com/acepanel/panel/v3/pkg/types"
"github.com/acepanel/panel/v3/pkg/webserver"
"github.com/acepanel/panel/v3/pkg/webserver/nginx"
webservertypes "github.com/acepanel/panel/v3/pkg/webserver/types"
)
type websiteRepo struct {
t *gotext.Locale
db *gorm.DB
setting biz.SettingRepo
}
func NewWebsiteRepo(db *gorm.DB, t *gotext.Locale, settingRepo biz.SettingRepo) biz.WebsiteRepo {
return &websiteRepo{
t: t,
db: db,
setting: settingRepo,
}
}
func (r *websiteRepo) GetRewrites() (map[string]string, error) {
d, err := r.dialect()
if err != nil {
return make(map[string]string), nil
}
entries, err := embed.RewritesFS.ReadDir(filepath.Join("rewrites", d.RewritesDir()))
if err != nil {
return make(map[string]string), nil
}
rw := make(map[string]string)
for _, entry := range entries {
if entry.IsDir() {
continue
}
if content, err := embed.RewritesFS.ReadFile(filepath.Join("rewrites", d.RewritesDir(), entry.Name())); err == nil {
rw[strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))] = string(content)
}
}
return rw, nil
}
func (r *websiteRepo) UpdateDefaultConfig(req *request.WebsiteDefaultConfig) error {
d, err := r.dialect()
if err != nil {
return err
}
htmlPath := d.HTMLDir()
if err = io.Write(filepath.Join(htmlPath, "index.html"), req.Index, 0644); err != nil {
return err
}
if err = io.Write(filepath.Join(htmlPath, "stop.html"), req.Stop, 0644); err != nil {
return err
}
if req.NotFound != "" {
if err = io.Write(filepath.Join(htmlPath, "404.html"), req.NotFound, 0644); err != nil {
return err
}
}
if err = r.setting.SetSlice(biz.SettingKeyWebsiteTLSVersions, req.TLSVersions); err != nil {
return err
}
if err = r.setting.Set(biz.SettingKeyWebsiteListenIPv6, strconv.FormatBool(req.ListenIPv6)); err != nil {
return err
}
return r.ReloadWebServer()
}
func (r *websiteRepo) Count() (int64, error) {
var count int64
if err := r.db.Model(&biz.Website{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return nil, err
}
vhost, err := r.getVhost(website)
if err != nil {
return nil, err
}
return r.loadSetting(website, vhost)
}
// loadSetting 从 vhost 读取站点的完整设置
func (r *websiteRepo) loadSetting(website *biz.Website, vhost webservertypes.Vhost) (*types.WebsiteSetting, error) {
var err error
setting := new(types.WebsiteSetting)
setting.ID = website.ID
setting.Name = website.Name
setting.Type = string(website.Type)
setting.Path = website.Path
setting.SSL = website.SSL
// 监听地址
setting.Listens = vhost.Listen()
// 域名
domains := vhost.ServerName()
domains = lo.Map(domains, func(d string, _ int) string { return tools.UnwrapIPv6(d) })
domains, err = punycode.DecodeDomains(domains)
if err != nil {
return nil, err
}
setting.Domains = domains
// 运行目录
setting.Root = vhost.Root()
// 默认文档
setting.Index = vhost.Index()
// 防跨站
if website.Type == biz.WebsiteTypePHP && io.Exists(filepath.Join(setting.Root, ".user.ini")) {
userIni, _ := io.Read(filepath.Join(setting.Root, ".user.ini"))
if strings.Contains(userIni, "open_basedir") {
setting.OpenBasedir = true
}
}
// SSL,切换 Web 服务器后配置可能尚未生成
if sslConfig := vhost.SSLConfig(); setting.SSL && sslConfig != nil {
setting.HTTPRedirect = sslConfig.HTTPRedirect
setting.HSTS = sslConfig.HSTS
setting.OCSP = sslConfig.OCSP
setting.SSLProtocols = sslConfig.Protocols
}
// 证书
crt, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem"))
setting.SSLCert = string(crt)
key, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "private.key"))
setting.SSLKey = string(key)
// 解析证书信息
if decode, err := cert.ParseCert(crt); err == nil {
setting.SSLNotBefore = decode.NotBefore.Format(time.DateTime)
setting.SSLNotAfter = decode.NotAfter.Format(time.DateTime)
setting.SSLIssuer = decode.Issuer.CommonName
setting.SSLOCSPServer = decode.OCSPServer
// 合并 DNSNames 和 IPAddresses
setting.SSLDNSNames = decode.DNSNames
for _, ip := range decode.IPAddresses {
setting.SSLDNSNames = append(setting.SSLDNSNames, ip.String())
}
}
// 访问日志
if setting.AccessLog = vhost.AccessLog(); setting.AccessLog == "" {
setting.AccessLog = fmt.Sprintf("%s/sites/%s/log/access.log", app.Root, website.Name)
}
// 错误日志
if setting.ErrorLog = vhost.ErrorLog(); setting.ErrorLog == "" {
setting.ErrorLog = fmt.Sprintf("%s/sites/%s/log/error.log", app.Root, website.Name)
}
// PHP 网站特有
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
setting.PHP = phpVhost.PHP()
// 伪静态
setting.Rewrite = phpVhost.Config("010-rewrite.conf", webservertypes.ScopeSite)
}
// 反向代理网站特有
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
setting.Upstreams = proxyVhost.Upstreams()
setting.Proxies = proxyVhost.Proxies()
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
setting.Redirects = redirectVhost.Redirects()
}
// 高级设置(限流限速、真实 IP、基本认证)
setting.RateLimit = vhost.RateLimit()
setting.RealIP = vhost.RealIP()
// 读取基本认证规则及用户列表
for _, auth := range vhost.BasicAuth() {
setting.BasicAuth = append(setting.BasicAuth, types.WebsiteBasicAuth{
Path: auth.Path,
Users: r.readBasicAuthUsers(auth.UserFile),
})
}
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
setting.CustomConfigs = r.getCustomConfigs(configDir)
// 访问统计
setting.StatEnabled = vhost.Config("021-stats-log.conf", webservertypes.ScopeSite) != ""
setting.LSCache = vhost.Config("020-lscache.conf", webservertypes.ScopeSite) != ""
return setting, err
}
func (r *websiteRepo) GetByName(name string) (*types.WebsiteSetting, error) {
website := new(biz.Website)
if err := r.db.Where("name", name).First(website).Error; err != nil {
return nil, err
}
return r.Get(website.ID)
}
func (r *websiteRepo) List(typ string, page, limit uint) ([]*biz.Website, int64, error) {
websites := make([]*biz.Website, 0)
var total int64
if err := r.db.Model(&biz.Website{}).Count(&total).Error; err != nil {
return nil, 0, err
}
query := r.db
if typ != "" && typ != "all" {
query = query.Where("type = ?", typ)
}
if err := query.Order("id DESC").Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&websites).Error; err != nil {
return nil, 0, err
}
// 取证书剩余有效时间和PHP版本
d, dErr := r.dialect()
for _, website := range websites {
crt, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem"))
if decode, err := cert.ParseCert(crt); err == nil {
hours := time.Until(decode.NotAfter).Hours()
website.CertExpire = fmt.Sprintf("%.2f", hours/24)
}
if dErr != nil {
continue
}
vhost, err := newVhost(d, website)
if err != nil {
continue
}
if php, ok := vhost.(webservertypes.PHPVhost); ok {
website.PHP = php.PHP()
}
// 获取域名
if domains, err := punycode.DecodeDomains(vhost.ServerName()); err == nil {
website.Domains = domains
}
}
return websites, total, nil
}
func (r *websiteRepo) Create(req *request.WebsiteCreate) (*biz.Website, error) {
w := &biz.Website{
Name: req.Name,
Type: biz.WebsiteType(req.Type),
Status: true,
Path: req.Path,
SSL: false,
Remark: req.Remark,
}
d, err := r.dialect()
if err != nil {
return nil, err
}
vhost, err := newVhost(d, w)
if err != nil {
return nil, err
}
// 创建配置文件目录
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "config", "site"), 0600); err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "config", "shared"), 0600); err != nil {
return nil, err
}
// 创建日志目录
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "log"), 0755); err != nil {
return nil, err
}
// 监听地址
listens := lo.Map(req.Listens, func(listen string, _ int) webservertypes.Listen {
return webservertypes.Listen{Address: listen}
})
listenIPv6, err := r.setting.GetBool(biz.SettingKeyWebsiteListenIPv6, false)
if err != nil {
return nil, err
}
if d.Features().IPv6Listen && listenIPv6 {
listens = webserver.WithIPv6Listens(listens)
}
if err = vhost.SetListen(listens); err != nil {
return nil, err
}
// 域名
domains, err := punycode.EncodeDomains(req.Domains)
if err != nil {
return nil, err
}
domains = lo.Map(domains, func(d string, _ int) string { return tools.WrapIPv6(d) })
if err = vhost.SetServerName(domains); err != nil {
return nil, err
}
// 运行目录
if err = vhost.SetRoot(req.Path); err != nil {
return nil, err
}
// 日志
if err = vhost.SetAccessLog(filepath.Join(app.Root, "sites", req.Name, "log", "access.log")); err != nil {
return nil, err
}
if err = vhost.SetErrorLog(filepath.Join(app.Root, "sites", req.Name, "log", "error.log")); err != nil {
return nil, err
}
// 404 页面、PHP 缓存或静态 SPA 回退
if err = writeTypeConfigs(d, vhost, w.Type); err != nil {
return nil, err
}
// 反向代理支持
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
if err = proxyVhost.SetProxies([]webservertypes.Proxy{
{
Location: "^~ /",
Pass: req.Proxy,
},
}); err != nil {
return nil, err
}
}
// PHP 支持
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
if err = phpVhost.SetPHP(req.PHP); err != nil {
return nil, err
}
if err = phpVhost.SetIndex([]string{"index.php", "index.html"}); err != nil {
return nil, err
}
if err = phpVhost.SetRawConfig("010-rewrite.conf", webservertypes.ScopeSite, ""); err != nil {
return nil, err
}
}
// 初始化网站目录
if err = os.MkdirAll(req.Path, 0755); err != nil {
return nil, err
}
var index []byte
switch app.Locale {
case "zh_CN":
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index_zh_CN.html"))
case "zh_TW":
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index_zh_TW.html"))
default:
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index.html"))
}
if err != nil {
return nil, errors.New(r.t.Get("failed to get index template file: %v", err))
}
if err = io.Write(filepath.Join(req.Path, "index.html"), string(index), 0644); err != nil {
return nil, err
}
var notFound []byte
// 如果存在自定义 404 页面,则使用自定义的
custom404Path := filepath.Join(d.HTMLDir(), "404.html")
if io.Exists(custom404Path) {
notFound, _ = os.ReadFile(custom404Path)
} else {
switch app.Locale {
case "zh_CN":
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404_zh_CN.html"))
case "zh_TW":
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404_zh_TW.html"))
default:
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404.html"))
}
}
if err = io.Write(filepath.Join(req.Path, "404.html"), string(notFound), 0644); err != nil {
return nil, err
}
// 写配置
if err = vhost.SetConfig("001-acme.conf", webservertypes.ScopeSite, ""); err != nil {
return nil, err
}
// 访问统计默认启用
if d.Features().Stat {
if err = r.enableStat(vhost, req.Name); err != nil {
return nil, err
}
}
if err = vhost.Save(); err != nil {
return nil, err
}
if err = io.Write(filepath.Join(app.Root, "sites", req.Name, "config", "fullchain.pem"), "", 0600); err != nil {
return nil, err
}
if err = io.Write(filepath.Join(app.Root, "sites", req.Name, "config", "private.key"), "", 0600); err != nil {
return nil, err
}
// 设置目录权限
// sites/site_name 0755 root
// sites/site_name/config 0600 root
// sites/site_name/log 0701 root
// sites/site_name/public 0755 www
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name), 0755); err != nil {
return nil, err
}
if err = io.Chmod(req.Path, 0755); err != nil {
return nil, err
}
if err = io.Chown(req.Path, "www", "www"); err != nil {
return nil, err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name, "log"), 0701); err != nil {
return nil, err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name, "config"), 0600); err != nil {
return nil, err
}
// PHP 网站默认开启防跨站
if req.Type == "php" {
userIni := filepath.Join(req.Path, ".user.ini")
if !io.Exists(userIni) {
if err = io.Write(userIni, fmt.Sprintf("open_basedir=%s:/tmp/", req.Path), 0644); err != nil {
return nil, err
}
}
_, _ = shell.Execf(`chattr +i '%s'`, userIni)
}
// 创建面板网站
if err = r.db.Create(w).Error; err != nil {
return nil, err
}
return w, nil
}
func (r *websiteRepo) Update(req *request.WebsiteUpdate) (*biz.Website, error) {
website := new(biz.Website)
if err := r.db.Where("id", req.ID).First(website).Error; err != nil {
return nil, err
}
if err := r.applyUpdate(req, website); err != nil {
return nil, err
}
return website, nil
}
func (r *websiteRepo) SwitchType(req *request.WebsiteSwitchType) (*biz.Website, error) {
website := new(biz.Website)
if err := r.db.Where("id", req.ID).First(website).Error; err != nil {
return nil, err
}
targetType := biz.WebsiteType(req.Type)
if targetType == website.Type {
return nil, errors.New(r.t.Get("website type is unchanged"))
}
setting, err := r.Get(req.ID)
if err != nil {
return nil, err
}
d, err := r.dialect()
if err != nil {
return nil, err
}
customConfigs := lo.Map(setting.CustomConfigs, func(config types.WebsiteCustomConfig, _ int) request.WebsiteCustomConfig {
return request.WebsiteCustomConfig{
Name: config.Name,
Scope: config.Scope,
Content: config.Content,
}
})
update := &request.WebsiteUpdate{
ID: website.ID,
Listens: setting.Listens,
Domains: setting.Domains,
Path: setting.Path,
Root: setting.Root,
Index: []string{"index.html"},
SSL: setting.SSL,
SSLCert: setting.SSLCert,
SSLKey: setting.SSLKey,
HSTS: setting.HSTS,
OCSP: setting.OCSP,
HTTPRedirect: setting.HTTPRedirect,
SSLProtocols: setting.SSLProtocols,
Redirects: setting.Redirects,
StatEnabled: setting.StatEnabled,
AccessLog: setting.AccessLog,
ErrorLog: setting.ErrorLog,
RateLimit: setting.RateLimit,
RealIP: setting.RealIP,
BasicAuth: setting.BasicAuth,
CustomConfigs: customConfigs,
}
switch targetType {
case biz.WebsiteTypePHP:
update.Index = []string{"index.php", "index.html"}
update.PHP = req.PHP
update.OpenBasedir = true
case biz.WebsiteTypeProxy:
update.Proxies = []webservertypes.Proxy{{
Location: "^~ /",
Pass: req.Proxy,
}}
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
backupDir := fmt.Sprintf("%s.switch-backup-%d", configDir, time.Now().UnixNano())
if err = os.Rename(configDir, backupDir); err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Join(configDir, "site"), 0600); err != nil {
_ = io.Remove(configDir)
_ = os.Rename(backupDir, configDir)
return nil, err
}
if err = os.MkdirAll(filepath.Join(configDir, "shared"), 0600); err != nil {
_ = io.Remove(configDir)
_ = os.Rename(backupDir, configDir)
return nil, err
}
oldType := website.Type
oldPath := website.Path
oldSSL := website.SSL
oldUpdatedAt := website.UpdatedAt
userIniPath := filepath.Join(setting.Root, ".user.ini")
oldUserIni, userIniErr := os.ReadFile(userIniPath)
if userIniErr != nil && !os.IsNotExist(userIniErr) {
_ = io.Remove(configDir)
_ = os.Rename(backupDir, configDir)
return nil, userIniErr
}
oldUserIniExists := userIniErr == nil
databaseUpdated := false
restore := func(switchErr error) (*biz.Website, error) {
var restoreErr error
restoreErr = errors.Join(restoreErr, io.Remove(configDir))
restoreErr = errors.Join(restoreErr, os.Rename(backupDir, configDir))
if databaseUpdated {
restoreErr = errors.Join(restoreErr, r.db.Model(&biz.Website{}).Where("id = ?", website.ID).UpdateColumns(map[string]any{
"type": oldType,
"path": oldPath,
"ssl": oldSSL,
"updated_at": oldUpdatedAt,
}).Error)
}
restoreErr = errors.Join(restoreErr, io.Remove(userIniPath))
if oldUserIniExists {
restoreErr = errors.Join(restoreErr, io.Write(userIniPath, string(oldUserIni), 0644))
if setting.OpenBasedir {
_, attrErr := shell.Execf(`chattr +i '%s'`, userIniPath)
restoreErr = errors.Join(restoreErr, attrErr)
}
}
return nil, errors.Join(switchErr, restoreErr)
}
website.Type = targetType
if err = r.applyUpdate(update, website); err != nil {
return restore(err)
}
databaseUpdated = true
vhost, err := newVhost(d, website)
if err != nil {
return restore(err)
}
if err = vhost.SetConfig("001-acme.conf", webservertypes.ScopeSite, ""); err != nil {
return restore(err)
}
if err = writeTypeConfigs(d, vhost, targetType); err != nil {
return restore(err)
}
if err = vhost.SetEnable(website.Status); err != nil {
return restore(err)
}
if err = vhost.Save(); err != nil {
return restore(err)
}
if oldType == biz.WebsiteTypePHP && targetType != biz.WebsiteTypePHP && setting.OpenBasedir {
if err = io.Remove(userIniPath); err != nil {
return restore(err)
}
}
if err = r.ReloadWebServer(); err != nil {
_, switchErr := restore(err)
if reloadErr := r.ReloadWebServer(); reloadErr != nil {
switchErr = errors.Join(switchErr, reloadErr)
}
return nil, switchErr
}
_ = io.Remove(backupDir)
return website, nil
}
// Rebuild 用上一个 Web 服务器留下的配置为当前 Web 服务器重建站点配置,返回是否重建及需人工处理的提示
func (r *websiteRepo) Rebuild(website *biz.Website) (bool, []string, error) {
d, err := r.dialect()
if err != nil {
return false, nil, err
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
source, ok := sourceDialect(configDir)
if !ok || source.Type == d.Type {
return false, nil, nil
}
oldVhost, err := newVhost(source, website)
if err != nil {
return false, nil, err
}
setting, err := r.loadSetting(website, oldVhost)
if err != nil {
return false, nil, err
}
// 目标不支持的 IPv6 监听直接丢弃
listens := setting.Listens
if !d.Features().IPv6Listen {
listens = lo.Filter(listens, func(listen webservertypes.Listen, _ int) bool {
return !strings.HasPrefix(listen.Address, "[")
})
}
if len(listens) == 0 {
listens = []webservertypes.Listen{{Address: "80"}}
}
index := setting.Index
if len(index) == 0 {
index = []string{"index.html"}
if website.Type == biz.WebsiteTypePHP {
index = []string{"index.php", "index.html"}
}
}
update := &request.WebsiteUpdate{
ID: website.ID,
Listens: listens,
Domains: setting.Domains,
Path: setting.Path,
Root: setting.Root,
Index: index,
SSL: setting.SSL,
SSLCert: setting.SSLCert,
SSLKey: setting.SSLKey,
HSTS: setting.HSTS,
OCSP: setting.OCSP,
HTTPRedirect: setting.HTTPRedirect,
SSLProtocols: setting.SSLProtocols,
PHP: setting.PHP,
Rewrite: setting.Rewrite,
OpenBasedir: setting.OpenBasedir,
Upstreams: setting.Upstreams,
Proxies: setting.Proxies,
Redirects: setting.Redirects,
// 目标支持访问统计时沿用原开关,来源不支持则默认开启
StatEnabled: d.Features().Stat && (setting.StatEnabled || !source.Features().Stat),
LSCache: d.Features().LSCache && setting.LSCache,
AccessLog: setting.AccessLog,
ErrorLog: setting.ErrorLog,
RateLimit: setting.RateLimit,
RealIP: setting.RealIP,
BasicAuth: setting.BasicAuth,
CustomConfigs: lo.Map(setting.CustomConfigs, func(config types.WebsiteCustomConfig, _ int) request.WebsiteCustomConfig {
return request.WebsiteCustomConfig{Name: config.Name, Scope: config.Scope, Content: config.Content}
}),
}
// 伪静态与自定义配置是各服务器自己的语法,语法不同时只能停用,原文件带后缀保留
var notes []string
sameSyntax := source.RewritesDir() == d.RewritesDir()
if !sameSyntax && (setting.Rewrite != "" || len(setting.CustomConfigs) > 0) {
update.Rewrite = ""
update.CustomConfigs = nil
notes = append(notes, r.t.Get("rewrite rules and custom configs are written for %s and have been disabled, the originals are kept as *.conf.%s", source.Type, source.Type))
}
backupDir := fmt.Sprintf("%s.rebuild-backup-%d", configDir, time.Now().UnixNano())
if err = os.Rename(configDir, backupDir); err != nil {
return false, nil, err
}
restore := func(rebuildErr error) (bool, []string, error) {
removeErr := io.Remove(configDir)
return false, nil, errors.Join(rebuildErr, removeErr, os.Rename(backupDir, configDir))
}
for _, scope := range []string{"site", "shared"} {
if err = os.MkdirAll(filepath.Join(configDir, scope), 0600); err != nil {
return restore(err)
}
}
if !sameSyntax {
if err = stashFragments(backupDir, configDir, string(source.Type)); err != nil {
return restore(err)
}
}
if err = r.applyUpdate(update, website); err != nil {
return restore(err)
}
vhost, err := newVhost(d, website)
if err != nil {
return restore(err)
}
if err = vhost.SetConfig("001-acme.conf", webservertypes.ScopeSite, ""); err != nil {
return restore(err)
}
if err = writeTypeConfigs(d, vhost, website.Type); err != nil {
return restore(err)
}
if err = vhost.SetEnable(website.Status); err != nil {
return restore(err)
}
if err = vhost.Save(); err != nil {
return restore(err)
}
_ = io.Remove(backupDir)
return true, notes, nil
}
// sourceDialect 按站点目录里最新修改的主配置文件识别上一个 Web 服务器
func sourceDialect(configDir string) (webserver.Dialect, bool) {
var latest webserver.Dialect
var modTime time.Time
for _, t := range webserver.Types() {
d, err := webserver.Get(t)
if err != nil {
continue
}
info, err := os.Stat(filepath.Join(configDir, d.ConfigFile()))
if err != nil || (!modTime.IsZero() && !info.ModTime().After(modTime)) {
continue
}
latest, modTime = d, info.ModTime()
}
return latest, !modTime.IsZero()
}
// stashFragments 把旧服务器的伪静态与自定义配置片段带后缀存入新目录,避免被新服务器加载
func stashFragments(from, to, suffix string) error {
for _, scope := range []string{"site", "shared"} {
entries, err := os.ReadDir(filepath.Join(from, scope))
if err != nil {
continue
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || (name != "010-rewrite.conf" && !isCustomConfig(name)) {
continue
}
content, err := os.ReadFile(filepath.Join(from, scope, name))
if err != nil {
return err
}
if err = os.WriteFile(filepath.Join(to, scope, name+"."+suffix), content, 0600); err != nil {
return err
}
}
}
return nil
}
// isCustomConfig 判断文件名是否为自定义配置片段(800-999 序号)
func isCustomConfig(name string) bool {
if !strings.HasSuffix(name, ".conf") {
return false
}
parts := strings.SplitN(name, "-", 2)
if len(parts) < 2 {
return false
}
num, err := strconv.Atoi(parts[0])
return err == nil && num >= customConfigStartNum && num <= customConfigEndNum
}
// applyUpdate 将更新请求应用到网站配置与实体,供 Update 复用
func (r *websiteRepo) applyUpdate(req *request.WebsiteUpdate, website *biz.Website) error {
d, err := r.dialect()
if err != nil {
return err
}
vhost, err := newVhost(d, website)
if err != nil {
return err
}
// 监听地址
if req.SSL && !website.SSL {
listenIPv6, getErr := r.setting.GetBool(biz.SettingKeyWebsiteListenIPv6, false)
if getErr != nil {
return getErr
}
req.Listens = d.MergeHTTPSListens(req.Listens, listenIPv6)
}
// 关闭 HTTPS 时移除 SSL 专用监听(含 IPv6),避免残留 ssl/quic 参数导致 nginx 无法启动
if !req.SSL {
req.Listens = lo.Filter(req.Listens, func(l webservertypes.Listen, _ int) bool {
return !slices.Contains(l.Args, "ssl") && !slices.Contains(l.Args, "quic")
})
}
if err = vhost.SetListen(req.Listens); err != nil {
return err
}
// 域名
domains, err := punycode.EncodeDomains(req.Domains)
if err != nil {
return err
}
domains = lo.Map(domains, func(d string, _ int) string { return tools.WrapIPv6(d) })
if err = vhost.SetServerName(domains); err != nil {
return err
}
// 首页文件
if err = vhost.SetIndex(req.Index); err != nil {
return err
}
// 运行目录
if !io.Exists(req.Root) {
return errors.New(r.t.Get("runtime directory does not exist"))
}
if err = vhost.SetRoot(req.Root); err != nil {
return err
}
// 运行目录
if !io.Exists(req.Path) {
return errors.New(r.t.Get("website directory does not exist"))
}
website.Path = req.Path
// SSL
certPath := filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem")
keyPath := filepath.Join(app.Root, "sites", website.Name, "config", "private.key")
if err = io.Write(certPath, req.SSLCert, 0600); err != nil {
return err
}
if err = io.Write(keyPath, req.SSLKey, 0600); err != nil {
return err
}
website.SSL = req.SSL
if req.SSL {
if _, err = cert.ParseCert([]byte(req.SSLCert)); err != nil {
return errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err = cert.ParseKey([]byte(req.SSLKey)); err != nil {
return errors.New(r.t.Get("failed to parse private key: %v", err))
}
// 检查证书是否已存在于面板的证书管理中,如果不存在则作为本地证书上传
existing := new(biz.Cert)
err = r.db.Where("TRIM(cert, char(9) || char(10) || char(13) || ' ') = ?", strings.TrimSpace(req.SSLCert)).First(existing).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
certInfo, _ := cert.ParseCert([]byte(req.SSLCert))
sans := certInfo.DNSNames
for _, ip := range certInfo.IPAddresses {
sans = append(sans, ip.String())
}
existing = &biz.Cert{
Type: "upload",
Domains: sans,
Cert: strings.TrimSpace(req.SSLCert),
Key: strings.TrimSpace(req.SSLKey),
}
if err = r.db.Create(existing).Error; err != nil {
return err
}
}
// 绑定证书,使续签后能自动部署
website.CertID = existing.ID
quic := false
for _, listen := range req.Listens {
if slices.Contains(listen.Args, "quic") {
quic = true
break
}
}
defaultTLSVersions, _ := r.setting.GetSlice(biz.SettingKeyWebsiteTLSVersions)
if err = vhost.SetSSLConfig(&webservertypes.SSLConfig{
Cert: certPath,
Key: keyPath,
Protocols: lo.If(len(req.SSLProtocols) > 0, req.SSLProtocols).Else(defaultTLSVersions),
HSTS: req.HSTS,
OCSP: req.OCSP,
HTTPRedirect: req.HTTPRedirect,
AltSvc: lo.If(quic, `'h3=":$server_port"; ma=2592000'`).Else(``),
}); err != nil {
return err
}
} else {
if err = vhost.ClearSSL(); err != nil {
return err
}
// 关闭 HTTPS 后不应再被证书续签覆盖
website.CertID = 0
}
// PHP
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
if err = phpVhost.SetPHP(req.PHP); err != nil {
return err
}
// 伪静态
if err = phpVhost.SetRawConfig("010-rewrite.conf", webservertypes.ScopeSite, req.Rewrite); err != nil {
return err
}
// 防跨站
if !strings.HasSuffix(req.Root, "/") {
req.Root += "/"
}
userIni := filepath.Join(req.Root, ".user.ini")
if req.OpenBasedir {
if !io.Exists(userIni) || req.Root != website.Path {
// 之前没有开启,或者修改了运行目录,重新写入
if err = io.Write(userIni, fmt.Sprintf("open_basedir=%s:%s:/tmp/", req.Root, req.Path), 0644); err != nil {
return err
}
}
_, _ = shell.Execf(`chattr +i '%s'`, userIni)
} else if io.Exists(userIni) {
if err = io.Remove(userIni); err != nil {
return err
}
}
}
// 反向代理
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
if err = proxyVhost.SetUpstreams(req.Upstreams); err != nil {
return err
}
if err = proxyVhost.SetProxies(req.Proxies); err != nil {
return err
}
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
if err = redirectVhost.SetRedirects(req.Redirects); err != nil {
return err
}
}
// 高级设置(日志路径、限流限速、真实 IP、基本认证)
// 日志路径
if req.AccessLog != "" {
if err = vhost.SetAccessLog(req.AccessLog); err != nil {
return err
}
}
if req.ErrorLog != "" {
if err = vhost.SetErrorLog(req.ErrorLog); err != nil {
return err
}
}
// 限流限速
if req.RateLimit != nil {
if err = vhost.SetRateLimit(req.RateLimit); err != nil {
return err
}
} else {
if err = vhost.ClearRateLimit(); err != nil {
return err
}
}
// 真实 IP 配置
if req.RealIP != nil {
if err = vhost.SetRealIP(req.RealIP); err != nil {
return err
}
} else {
if err = vhost.ClearRealIP(); err != nil {
return err
}
}
// 基本认证:每条规则一个独立的 htpasswd 文件
r.removeBasicAuthFiles(website.Name)
auths := make([]webservertypes.BasicAuth, 0, len(req.BasicAuth))
for i, rule := range req.BasicAuth {
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, fmt.Sprintf("htpasswd_%d", i))
if err = writeBasicAuthUsers(d, htpasswdPath, rule.Users); err != nil {
return err
}
// 路径归一化:空 -> "/",去尾部斜杠
auths = append(auths, webservertypes.BasicAuth{Path: "/" + strings.Trim(rule.Path, "/ "), UserFile: htpasswdPath})
}
if err = vhost.SetBasicAuth(auths); err != nil {
return err
}
// 访问统计
if d.Features().Stat {
if req.StatEnabled {
if err = r.enableStat(vhost, website.Name); err != nil {
return err
}
} else {
_ = vhost.RemoveConfig("010-stat-format.conf", webservertypes.ScopeShared)
_ = vhost.RemoveConfig("021-stats-log.conf", webservertypes.ScopeSite)
}
}
// LiteSpeed 页面缓存
if d.Features().LSCache {
if req.LSCache {
if err = vhost.SetConfig("020-lscache.conf", webservertypes.ScopeSite, d.LSCacheConf(website.Name)); err != nil {
return err
}
} else {
_ = vhost.RemoveConfig("020-lscache.conf", webservertypes.ScopeSite)
}
}
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
if err = r.saveCustomConfigs(configDir, req.CustomConfigs); err != nil {
return err
}
// 保存配置
if err = vhost.Save(); err != nil {
return err
}
if err = r.db.Save(website).Error; err != nil {
return err
}
return nil
}
func (r *websiteRepo) GetForDelete(id uint) (*biz.Website, error) {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return nil, err
}
return website, nil
}
func (r *websiteRepo) RemoveFiles(name string, removePath bool) error {
if removePath {
_ = io.Remove(filepath.Join(app.Root, "sites", name))
} else {
// 仅删除配置和日志
_ = io.Remove(filepath.Join(app.Root, "sites", name, "config"))
_ = io.Remove(filepath.Join(app.Root, "sites", name, "log"))
r.removeBasicAuthFiles(name)
}
return nil
}
func (r *websiteRepo) Delete(website *biz.Website) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(website).Error; err != nil {
return err
}
// HTTP 验证依赖网站,证书失去全部网站后无法继续自动续签
return tx.Model(&biz.Cert{}).
Where("id = ? AND dns_id = 0", website.CertID).
Where("NOT EXISTS (SELECT 1 FROM websites WHERE cert_id = ?)", website.CertID).
Update("auto_renewal", false).Error
})
}
func (r *websiteRepo) UpdateRemark(id uint, remark string) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return err
}
website.Remark = remark
return r.db.Save(website).Error
}
func (r *websiteRepo) ResetConfig(id uint) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(&website).Error; err != nil {
return err
}
d, err := r.dialect()
if err != nil {
return err
}
setting, err := r.Get(id)
if err != nil {
return err
}
listens := lo.Filter(setting.Listens, func(listen webservertypes.Listen, _ int) bool {
if slices.Contains(listen.Args, "ssl") || slices.Contains(listen.Args, "quic") {
return false
}
port := listen.Address
if strings.Contains(listen.Address, ":") {
if _, parsedPort, splitErr := net.SplitHostPort(listen.Address); splitErr == nil {
port = parsedPort
}
}
return !website.SSL || port != "443"
})
if len(listens) == 0 {
listens = []webservertypes.Listen{{Address: "80"}}
}
update := &request.WebsiteUpdate{
ID: website.ID,
Listens: listens,
Domains: setting.Domains,
Path: website.Path,
Root: setting.Root,
Index: []string{"index.html"},
SSL: false,
StatEnabled: d.Features().Stat,
AccessLog: filepath.Join(app.Root, "sites", website.Name, "log", "access.log"),
ErrorLog: filepath.Join(app.Root, "sites", website.Name, "log", "error.log"),
}
switch website.Type {
case biz.WebsiteTypePHP:
update.Index = []string{"index.php", "index.html"}
update.PHP = setting.PHP
update.OpenBasedir = true
case biz.WebsiteTypeProxy:
update.Upstreams = setting.Upstreams
if len(setting.Proxies) > 0 && setting.Proxies[0].Pass != "" {
update.Proxies = []webservertypes.Proxy{{
Location: "^~ /",
Pass: setting.Proxies[0].Pass,
}}
}
}
if website.Type == biz.WebsiteTypePHP {
if err = io.Remove(filepath.Join(setting.Root, ".user.ini")); err != nil {
return err
}
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
if err = io.Remove(configDir); err != nil {
return err
}
if err = os.MkdirAll(filepath.Join(configDir, "site"), 0600); err != nil {
return err
}
if err = os.MkdirAll(filepath.Join(configDir, "shared"), 0600); err != nil {
return err
}
website.Status = true
if err = r.applyUpdate(update, website); err != nil {
return err
}
vhost, err := newVhost(d, website)
if err != nil {
return err
}
if err = vhost.SetConfig("001-acme.conf", webservertypes.ScopeSite, ""); err != nil {
return err
}
if err = writeTypeConfigs(d, vhost, website.Type); err != nil {
return err
}
if err = vhost.Save(); err != nil {
return err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", website.Name, "config"), 0600); err != nil {
return err
}
if err = r.ReloadWebServer(); err != nil {
return err
}
return nil
}
func (r *websiteRepo) UpdateStatus(id uint, status bool) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(&website).Error; err != nil {
return err
}
vhost, err := r.getVhost(website)
if err != nil {
return err
}
if err = vhost.SetEnable(status); err != nil {
return err
}
if err = vhost.Save(); err != nil {
return err
}
website.Status = status
if err = r.db.Save(website).Error; err != nil {
return err
}
return r.ReloadWebServer()
}
func (r *websiteRepo) UpdateExpireAt(id uint, expireAt *time.Time) error {
return r.db.Model(&biz.Website{}).Where("id = ?", id).Update("expire_at", expireAt).Error
}
func (r *websiteRepo) UpdateCert(req *request.WebsiteUpdateCert) error {
website := new(biz.Website)
if err := r.db.Where("name", req.Name).First(&website).Error; err != nil {
return err
}
if _, err := cert.ParseCert([]byte(req.Cert)); err != nil {
return errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err := cert.ParseKey([]byte(req.Key)); err != nil {
return errors.New(r.t.Get("failed to parse private key: %v", err))
}
certPath := filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem")
keyPath := filepath.Join(app.Root, "sites", website.Name, "config", "private.key")
if err := io.Write(certPath, req.Cert, 0600); err != nil {
return err
}
if err := io.Write(keyPath, req.Key, 0600); err != nil {
return err
}
if website.SSL {
return r.ReloadWebServer()
}
return nil
}
// customConfigStartNum 自定义配置起始序号
const customConfigStartNum = 800
// customConfigEndNum 自定义配置结束序号
const customConfigEndNum = 999
// getCustomConfigs 获取网站自定义配置列表
func (r *websiteRepo) getCustomConfigs(configDir string) []types.WebsiteCustomConfig {
var configs []types.WebsiteCustomConfig
// 从 site 和 shared 目录读取自定义配置
for _, scope := range []string{"site", "shared"} {
scopeDir := filepath.Join(configDir, scope)
entries, err := os.ReadDir(scopeDir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// 匹配文件名格式: 800-999-name.conf
name := entry.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
// 解析序号
parts := strings.SplitN(name, "-", 2)
if len(parts) < 2 {
continue
}
num, err := strconv.Atoi(parts[0])
if err != nil || num < customConfigStartNum || num > customConfigEndNum {
continue
}
// 提取配置名称(去掉序号前缀和.conf后缀)
configName := strings.TrimSuffix(parts[1], ".conf")
if configName == "" {
continue
}
// 读取配置内容
content, err := io.Read(filepath.Join(scopeDir, name))
if err != nil {
continue
}
configs = append(configs, types.WebsiteCustomConfig{
Name: configName,
Scope: scope,
Content: content,
})
}
}
return configs
}
// saveCustomConfigs 保存网站自定义配置
func (r *websiteRepo) saveCustomConfigs(configDir string, configs []request.WebsiteCustomConfig) error {
if err := r.clearCustomConfigs(configDir); err != nil {
return err
}
// 分别跟踪 site 和 shared 目录的序号
siteNum := customConfigStartNum
sharedNum := customConfigStartNum
for _, cfg := range configs {
var num int
switch cfg.Scope {
case "site":
num = siteNum
siteNum++
case "shared":
num = sharedNum
sharedNum++
default:
return fmt.Errorf("invalid config scope: %s", cfg.Scope)
}
if num > customConfigEndNum {
return errors.New(r.t.Get("maximum number of custom configurations reached (limit: %d)", customConfigEndNum-customConfigStartNum+1))
}
fileName := fmt.Sprintf("%03d-%s.conf", num, cfg.Name)
filePath := filepath.Join(configDir, cfg.Scope, fileName)
if err := io.Write(filePath, cfg.Content, 0600); err != nil {
return fmt.Errorf("failed to write custom config: %w", err)
}
}
return nil
}
// clearCustomConfigs 清除网站自定义配置文件
func (r *websiteRepo) clearCustomConfigs(configDir string) error {
for _, scope := range []string{"site", "shared"} {
scopeDir := filepath.Join(configDir, scope)
entries, err := os.ReadDir(scopeDir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
parts := strings.SplitN(name, "-", 2)
if len(parts) < 2 {
continue
}
num, err := strconv.Atoi(parts[0])
if err != nil || num < customConfigStartNum || num > customConfigEndNum {
continue
}
filePath := filepath.Join(scopeDir, name)
if err = os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove custom config: %w", err)
}
}
}
return nil
}
// dialect 取当前 Web 服务器方言
func (r *websiteRepo) dialect() (webserver.Dialect, error) {
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
if err != nil {
return webserver.Dialect{}, err
}
return webserver.Get(webserver.Type(webServer))
}
// newVhost 按网站类型构造站点 vhost
func newVhost(d webserver.Dialect, website *biz.Website) (webservertypes.Vhost, error) {
return d.NewVhost(string(website.Type), filepath.Join(app.Root, "sites", website.Name, "config"))
}
func (r *websiteRepo) getVhost(website *biz.Website) (webservertypes.Vhost, error) {
d, err := r.dialect()
if err != nil {
return nil, err
}
return newVhost(d, website)
}
// writeTypeConfigs 写入站点类型对应的默认片段:404 页面、PHP 缓存或静态 SPA 回退
func writeTypeConfigs(d webserver.Dialect, vhost webservertypes.Vhost, typ biz.WebsiteType) error {
if err := vhost.SetConfig("010-error-404.conf", webservertypes.ScopeSite, d.ErrorPageConf()); err != nil {
return err
}
switch typ {
case biz.WebsiteTypePHP:
return vhost.SetConfig("010-cache.conf", webservertypes.ScopeSite, d.PHPCacheConf())
case biz.WebsiteTypeStatic:
return vhost.SetRawConfig("800-spa.conf", webservertypes.ScopeSite, d.SPAConf())
}
return nil
}
func (r *websiteRepo) ReloadWebServer() error {
d, err := r.dialect()
if err != nil {
return err
}
return d.ReloadIfRunning()
}
// readBasicAuthUsers 读取 htpasswd 文件中的用户列表
func (r *websiteRepo) readBasicAuthUsers(htpasswdPath string) map[string]string {
if !io.Exists(htpasswdPath) {
return make(map[string]string)
}
file, err := os.Open(htpasswdPath)
if err != nil {
return make(map[string]string)
}
defer func(file *os.File) { _ = file.Close() }(file)
users := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// htpasswd 格式: username:{PLAIN}password或直接username:password
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
users[parts[0]] = strings.TrimPrefix(parts[1], "{PLAIN}")
}
}
if scanner.Err() != nil {
return make(map[string]string)
}
return users
}
// removeBasicAuthFiles 删除站点的所有 htpasswd 文件
func (r *websiteRepo) removeBasicAuthFiles(siteName string) {
matches, _ := filepath.Glob(filepath.Join(app.Root, "sites", siteName, "htpasswd*"))
for _, match := range matches {
_ = io.Remove(match)
}
}
// writeBasicAuthUsers 将用户凭证写入 htpasswd 文件
func writeBasicAuthUsers(d webserver.Dialect, htpasswdPath string, users map[string]string) error {
var lines []string
for username, password := range users {
if username == "" || password == "" {
continue
}
lines = append(lines, d.HTPasswdLine(username, password))
}
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return io.Write(htpasswdPath, content, 0644) // 必须 0644Nginx 在运行中以 www 用户读取
}
// enableStat 写入 nginx 访问统计配置(log_format + syslog access_log
func (r *websiteRepo) enableStat(vhost webservertypes.Vhost, name string) error {
// nginx 的 syslog tag 与 log_format 名只允许字母数字和下划线
safeName := nginx.SafeName(name)
formatConf := fmt.Sprintf(`log_format ace_stat_%s escape=json
'{"site":"%s",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"ua":"$http_user_agent",'
'"ip":"$remote_addr",'
'"host":"$host",'
'"method":"$request_method",'
'"referer":"$http_referer",'
'"xff":"$http_x_forwarded_for",'
'"rt":$request_time,'
'"proto":"$server_protocol",'
'"port":"$remote_port",'
'"body":"$request_body",'
'"content_type":"$sent_http_content_type",'
'"req_length":$request_length,'
'"https":"$https",'
'"upstream_time":"$upstream_response_time",'
'"upstream_status":"$upstream_status"}';`, safeName, name)
if err := vhost.SetConfig("010-stat-format.conf", webservertypes.ScopeShared, formatConf); err != nil {
return err
}
logConf := fmt.Sprintf("client_body_in_single_buffer on;\naccess_log syslog:server=unix:/tmp/ace_stats.sock,nohostname,tag=%s ace_stat_%s;", safeName, safeName)
return vhost.SetConfig("021-stats-log.conf", webservertypes.ScopeSite, logConf)
}