mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-19 10:03:36 +08:00
- OLS 设置页新增真实 IP 标签,写入 conf/panel/realip.conf:有可信代理列表时 只信任列表来源,留空则信任所有来源 - 站点高级设置新增 LSCache 开关,写入 vhost 级 module cache 片段,缓存目录按站点隔离 - HTTP-01 投放与清理返回配置是否变化,求解器据此跳过空重载;OLS 探测运行中 服务是否已按当前指纹应答,命中即不重载 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1550 lines
44 KiB
Go
1550 lines
44 KiB
Go
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) // 必须 0644,Nginx 在运行中以 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)
|
||
}
|