mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-19 10:03:36 +08:00
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>
This commit is contained in:
@@ -42,6 +42,8 @@ func (s *App) Route(r chi.Router) {
|
||||
r.Post("/clear_error_log", s.ClearErrorLog)
|
||||
r.Get("/php", s.PHPList)
|
||||
r.Post("/php", s.SetPHP)
|
||||
r.Get("/realip", s.GetRealIP)
|
||||
r.Post("/realip", s.SetRealIP)
|
||||
}
|
||||
|
||||
func (s *App) Status() string {
|
||||
@@ -169,6 +171,37 @@ func (s *App) SetPHP(w http.ResponseWriter, r *http.Request) {
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// GetRealIP 读取服务器级真实 IP 配置
|
||||
func (s *App) GetRealIP(w http.ResponseWriter, r *http.Request) {
|
||||
realIP, err := openlitespeed.GetRealIP()
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, realIP)
|
||||
}
|
||||
|
||||
// SetRealIP 保存服务器级真实 IP 配置并重载
|
||||
func (s *App) SetRealIP(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := service.Bind[SetRealIP](r)
|
||||
if err != nil {
|
||||
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = openlitespeed.SetRealIP(openlitespeed.RealIP{Enabled: req.Enabled, Trusted: req.Trusted}); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if err = s.reload(); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
service.Success(w, nil)
|
||||
}
|
||||
|
||||
// reload 经方言重载,重载前会重建监听器配置
|
||||
func (s *App) reload() error {
|
||||
d, err := webserver.Get(webserver.TypeOpenLiteSpeed)
|
||||
|
||||
@@ -10,6 +10,12 @@ type SetPHP struct {
|
||||
LSAPI bool `form:"lsapi" json:"lsapi"`
|
||||
}
|
||||
|
||||
// SetRealIP 服务器级真实 IP 配置
|
||||
type SetRealIP struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
Trusted []string `form:"trusted" json:"trusted" validate:"unique && dive && ipcidr"`
|
||||
}
|
||||
|
||||
// PHPProtocol PHP 版本运行协议信息
|
||||
type PHPProtocol struct {
|
||||
Version uint `json:"version"`
|
||||
|
||||
@@ -217,6 +217,7 @@ func (r *websiteRepo) loadSetting(website *biz.Website, vhost webservertypes.Vho
|
||||
|
||||
// 访问统计
|
||||
setting.StatEnabled = vhost.Config("021-stats-log.conf", webservertypes.ScopeSite) != ""
|
||||
setting.LSCache = vhost.Config("020-lscache.conf", webservertypes.ScopeSite) != ""
|
||||
|
||||
return setting, err
|
||||
}
|
||||
@@ -699,6 +700,7 @@ func (r *websiteRepo) Rebuild(website *biz.Website) (bool, []string, error) {
|
||||
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,
|
||||
@@ -1043,6 +1045,17 @@ func (r *websiteRepo) applyUpdate(req *request.WebsiteUpdate, website *biz.Websi
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -72,6 +72,7 @@ type WebsiteUpdate struct {
|
||||
|
||||
// 高级设置
|
||||
StatEnabled bool `json:"stat_enabled"` // 是否启用访问统计
|
||||
LSCache bool `json:"lscache"` // LiteSpeed 页面缓存
|
||||
AccessLog string `json:"access_log"` // 访问日志路径
|
||||
ErrorLog string `json:"error_log"` // 错误日志路径
|
||||
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
|
||||
|
||||
+24
-9
@@ -26,12 +26,13 @@ import (
|
||||
pkgos "github.com/acepanel/panel/v3/pkg/os"
|
||||
)
|
||||
|
||||
// HTTPChallengeWriter 由 Web 服务器方言实现,负责投放、清理 HTTP-01 验证并重载服务
|
||||
// HTTPChallengeWriter 由 Web 服务器方言实现,负责投放、清理 HTTP-01 验证并重载服务,
|
||||
// 投放与清理的 bool 返回值表示配置是否变化,未变化则跳过重载
|
||||
type HTTPChallengeWriter interface {
|
||||
WriteSiteChallenge(conf, path, token string) error
|
||||
RemoveSiteChallenge(conf, path, token string) error
|
||||
WritePanelChallenge(conf string, names []string, tokens map[string]string) error
|
||||
RemovePanelChallenge(conf string) error
|
||||
WriteSiteChallenge(conf, path, token string) (bool, error)
|
||||
RemoveSiteChallenge(conf, path, token string) (bool, error)
|
||||
WritePanelChallenge(conf string, names []string, tokens map[string]string) (bool, error)
|
||||
RemovePanelChallenge(conf string) (bool, error)
|
||||
Reload() error
|
||||
}
|
||||
|
||||
@@ -83,7 +84,8 @@ func (s *panelSolver) Present(_ context.Context, challenge acme.Challenge) error
|
||||
|
||||
// 否则使用 web 服务器配置
|
||||
s.useBuiltin = false
|
||||
if err := s.writer.WritePanelChallenge(s.conf, s.names, s.tokens); err != nil {
|
||||
changed, err := s.writer.WritePanelChallenge(s.conf, s.names, s.tokens)
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -143,7 +145,8 @@ func (s *panelSolver) CleanUp(ctx context.Context, _ acme.Challenge) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.writer.RemovePanelChallenge(s.conf); err != nil {
|
||||
changed, err := s.writer.RemovePanelChallenge(s.conf)
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -177,10 +180,16 @@ func (s httpSolver) confsFor(domain string) []string {
|
||||
func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error {
|
||||
path := challenge.HTTP01ResourcePath()
|
||||
token := challenge.KeyAuthorization
|
||||
reload := false
|
||||
for _, conf := range s.confsFor(challenge.Identifier.Value) {
|
||||
if err := s.writer.WriteSiteChallenge(conf, path, token); err != nil {
|
||||
changed, err := s.writer.WriteSiteChallenge(conf, path, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reload = reload || changed
|
||||
}
|
||||
if !reload {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.writer.Reload()
|
||||
@@ -190,10 +199,16 @@ func (s httpSolver) Present(_ context.Context, challenge acme.Challenge) error {
|
||||
func (s httpSolver) CleanUp(_ context.Context, challenge acme.Challenge) error {
|
||||
path := challenge.HTTP01ResourcePath()
|
||||
token := challenge.KeyAuthorization
|
||||
reload := false
|
||||
for _, conf := range s.confsFor(challenge.Identifier.Value) {
|
||||
if err := s.writer.RemoveSiteChallenge(conf, path, token); err != nil {
|
||||
changed, err := s.writer.RemoveSiteChallenge(conf, path, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reload = reload || changed
|
||||
}
|
||||
if !reload {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.writer.Reload()
|
||||
|
||||
@@ -51,6 +51,7 @@ type WebsiteSetting struct {
|
||||
|
||||
// 高级设置
|
||||
StatEnabled bool `json:"stat_enabled"` // 是否启用访问统计
|
||||
LSCache bool `json:"lscache"` // LiteSpeed 页面缓存
|
||||
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
|
||||
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
|
||||
BasicAuth []WebsiteBasicAuth `json:"basic_auth"` // 基本认证规则
|
||||
|
||||
@@ -91,6 +91,10 @@ func (Dialect) SPAConf() string {
|
||||
return spaConf
|
||||
}
|
||||
|
||||
func (Dialect) LSCacheConf(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (Dialect) HTPasswdLine(username, password string) string {
|
||||
return username + ":" + password
|
||||
}
|
||||
@@ -128,45 +132,45 @@ func (Dialect) NewProxyVhost(configDir string) (types.ProxyVhost, error) {
|
||||
}
|
||||
|
||||
// WriteSiteChallenge token 落盘到站点配置目录旁的 acme-challenge,再用 Alias 映射
|
||||
func (Dialect) WriteSiteChallenge(conf, path, token string) error {
|
||||
func (Dialect) WriteSiteChallenge(conf, path, token string) (bool, error) {
|
||||
tokenDir := filepath.Join(filepath.Dir(conf), "acme-challenge")
|
||||
if err := writeToken(tokenDir, path, token); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open apache config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to open apache config %q: %w", conf, err)
|
||||
}
|
||||
_, err = file.WriteString(challengeConf(tokenDir))
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to apache config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to apache config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) RemoveSiteChallenge(conf, path, _ string) error {
|
||||
func (Dialect) RemoveSiteChallenge(conf, path, _ string) (bool, error) {
|
||||
tokenDir := filepath.Join(filepath.Dir(conf), "acme-challenge")
|
||||
_ = os.Remove(filepath.Join(tokenDir, filepath.Base(path)))
|
||||
|
||||
raw, err := os.ReadFile(conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read apache config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to read apache config %q: %w", conf, err)
|
||||
}
|
||||
content := strings.ReplaceAll(string(raw), challengeConf(tokenDir), "")
|
||||
if err = os.WriteFile(conf, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write to apache config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to apache config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[string]string) error {
|
||||
func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[string]string) (bool, error) {
|
||||
for path, token := range tokens {
|
||||
if err := writeToken(panelTokenDir, path, token); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,19 +191,19 @@ func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[strin
|
||||
b.WriteString("</VirtualHost>\n")
|
||||
|
||||
if err := os.WriteFile(conf, []byte(b.String()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write apache config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write apache config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) RemovePanelChallenge(conf string) error {
|
||||
func (Dialect) RemovePanelChallenge(conf string) (bool, error) {
|
||||
if err := os.WriteFile(conf, []byte(""), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write to config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to config %q: %w", conf, err)
|
||||
}
|
||||
_ = os.RemoveAll(panelTokenDir)
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeToken 将验证 token 写入目录,文件名取 URL 路径的最后一段
|
||||
|
||||
@@ -77,6 +77,10 @@ func (Dialect) SPAConf() string {
|
||||
return spaConf
|
||||
}
|
||||
|
||||
func (Dialect) LSCacheConf(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (Dialect) HTPasswdLine(username, password string) string {
|
||||
return username + ":{PLAIN}" + password
|
||||
}
|
||||
@@ -113,34 +117,34 @@ func (Dialect) NewProxyVhost(configDir string) (types.ProxyVhost, error) {
|
||||
return vhost, nil
|
||||
}
|
||||
|
||||
func (Dialect) WriteSiteChallenge(conf, path, token string) error {
|
||||
func (Dialect) WriteSiteChallenge(conf, path, token string) (bool, error) {
|
||||
file, err := os.OpenFile(conf, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open nginx config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to open nginx config %q: %w", conf, err)
|
||||
}
|
||||
_, err = file.WriteString(challengeConf(path, token))
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to nginx config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to nginx config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) RemoveSiteChallenge(conf, path, token string) error {
|
||||
func (Dialect) RemoveSiteChallenge(conf, path, token string) (bool, error) {
|
||||
raw, err := os.ReadFile(conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read nginx config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to read nginx config %q: %w", conf, err)
|
||||
}
|
||||
content := strings.ReplaceAll(string(raw), challengeConf(path, token), "")
|
||||
if err = os.WriteFile(conf, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write to nginx config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to nginx config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[string]string) error {
|
||||
func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[string]string) (bool, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("server {\n listen 80;\n")
|
||||
// 只有在包含 IPv6 地址时才监听 [::]:80,避免纯 IPv4 系统上 nginx 启动失败
|
||||
@@ -157,18 +161,18 @@ func (Dialect) WritePanelChallenge(conf string, names []string, tokens map[strin
|
||||
b.WriteString("}\n")
|
||||
|
||||
if err := os.WriteFile(conf, []byte(b.String()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write nginx config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write nginx config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (Dialect) RemovePanelChallenge(conf string) error {
|
||||
func (Dialect) RemovePanelChallenge(conf string) (bool, error) {
|
||||
if err := os.WriteFile(conf, []byte(""), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write to config %q: %w", conf, err)
|
||||
return false, fmt.Errorf("failed to write to config %q: %w", conf, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// challengeConf 单个 HTTP-01 验证的 location 片段
|
||||
|
||||
@@ -2,6 +2,8 @@ package openlitespeed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +17,9 @@ import (
|
||||
// 对任意 /.well-known/acme-challenge/<token> 直接应答 token.thumbprint,站点配置无需参与验证
|
||||
const acmeConf = PanelConfDir + "/acme.conf"
|
||||
|
||||
// acmeProbe 探测运行中 OLS 是否已按当前指纹应答所用的 token
|
||||
const acmeProbe = "ace-probe"
|
||||
|
||||
// acmeLeaseTTL 指纹租约时长,验证出错时 CleanUp 不会被调用,靠过期避免永久阻塞
|
||||
const acmeLeaseTTL = 2 * time.Minute
|
||||
|
||||
@@ -51,7 +56,7 @@ func (Dialect) PanelACMEConf() string {
|
||||
}
|
||||
|
||||
func (Dialect) Features() types.Features {
|
||||
return types.Features{}
|
||||
return types.Features{LSCache: true}
|
||||
}
|
||||
|
||||
func (Dialect) HTTPSListenArgs() []string {
|
||||
@@ -70,6 +75,15 @@ func (Dialect) SPAConf() string {
|
||||
return spaConf
|
||||
}
|
||||
|
||||
// LSCacheConf 站点级启用 LiteSpeed 页面缓存,缓存目录按站点隔离,由 OLS 自行创建
|
||||
func (Dialect) LSCacheConf(name string) string {
|
||||
cfg := &Config{}
|
||||
m := cfg.AddBlock("module", "cache")
|
||||
m.Add("ls_enabled", "1")
|
||||
m.Add("storagePath", ServerRoot+"/cachedata/"+name)
|
||||
return cfg.String()
|
||||
}
|
||||
|
||||
func (Dialect) HTPasswdLine(username, password string) string {
|
||||
return username + ":" + password
|
||||
}
|
||||
@@ -108,34 +122,42 @@ func (Dialect) NewProxyVhost(configDir string) (types.ProxyVhost, error) {
|
||||
return vhost, nil
|
||||
}
|
||||
|
||||
// WriteSiteChallenge 验证由 mod_acme 直接应答,只需保证当前账户指纹已写入,重载由求解器负责
|
||||
func (Dialect) WriteSiteChallenge(_, path, keyAuth string) error {
|
||||
return acquireThumbprint(path, keyAuth)
|
||||
// WriteSiteChallenge 验证由 mod_acme 直接应答,写入当前账户指纹;运行中的 OLS 已按该指纹应答时无需重载
|
||||
func (Dialect) WriteSiteChallenge(_, path, keyAuth string) (bool, error) {
|
||||
thumb, err := acquireThumbprint(path, keyAuth)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !acmeLive(thumb), nil
|
||||
}
|
||||
|
||||
func (Dialect) RemoveSiteChallenge(_, _, _ string) error {
|
||||
func (Dialect) RemoveSiteChallenge(_, _, _ string) (bool, error) {
|
||||
releaseThumbprint()
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WritePanelChallenge 同一订单的 token 属于同一账户,取任意一个占用一次指纹即可
|
||||
func (Dialect) WritePanelChallenge(_ string, _ []string, tokens map[string]string) error {
|
||||
func (Dialect) WritePanelChallenge(_ string, _ []string, tokens map[string]string) (bool, error) {
|
||||
for path, keyAuth := range tokens {
|
||||
return acquireThumbprint(path, keyAuth)
|
||||
thumb, err := acquireThumbprint(path, keyAuth)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !acmeLive(thumb), nil
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (Dialect) RemovePanelChallenge(_ string) error {
|
||||
func (Dialect) RemovePanelChallenge(_ string) (bool, error) {
|
||||
releaseThumbprint()
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// acquireThumbprint 从 keyAuth(token.thumbprint)取出账户指纹,等到允许切换后写入模块配置
|
||||
func acquireThumbprint(path, keyAuth string) error {
|
||||
func acquireThumbprint(path, keyAuth string) (string, error) {
|
||||
thumb, ok := strings.CutPrefix(keyAuth, filepath.Base(path)+".")
|
||||
if !ok || thumb == "" {
|
||||
return fmt.Errorf("invalid key authorization for %s", path)
|
||||
return "", fmt.Errorf("invalid key authorization for %s", path)
|
||||
}
|
||||
|
||||
l := &acmeLease
|
||||
@@ -153,14 +175,14 @@ func acquireThumbprint(path, keyAuth string) error {
|
||||
m.Add("acmeEnable", "1")
|
||||
m.Add("acmeThumbPrint", thumb)
|
||||
if err := os.WriteFile(acmeConf, []byte(cfg.String()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write acme config: %w", err)
|
||||
return "", fmt.Errorf("failed to write acme config: %w", err)
|
||||
}
|
||||
if l.thumb != thumb {
|
||||
l.thumb, l.active = thumb, 0
|
||||
}
|
||||
l.active++
|
||||
l.expires = time.Now().Add(acmeLeaseTTL)
|
||||
return nil
|
||||
return thumb, nil
|
||||
}
|
||||
|
||||
func releaseThumbprint() {
|
||||
@@ -170,3 +192,20 @@ func releaseThumbprint() {
|
||||
}
|
||||
acmeLease.Unlock()
|
||||
}
|
||||
|
||||
// acmeLive 探测运行中的 OLS 是否已按该指纹应答挑战,探测不通一律按需要重载处理
|
||||
func acmeLive(thumb string) bool {
|
||||
client := http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Get("http://127.0.0.1/.well-known/acme-challenge/" + acmeProbe)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return err == nil && resp.StatusCode == http.StatusOK && string(body) == acmeProbe+"."+thumb
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package openlitespeed
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// realIPConf 服务器级真实 IP 配置,OLS 只能在服务器级信任代理头,对所有站点生效
|
||||
const realIPConf = PanelConfDir + "/realip.conf"
|
||||
|
||||
// RealIP 从 X-Forwarded-For 取客户端 IP 的配置
|
||||
type RealIP struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Trusted []string `json:"trusted"` // 可信代理,留空则信任所有来源
|
||||
}
|
||||
|
||||
// GetRealIP 读取当前配置,文件不存在视为关闭
|
||||
func GetRealIP() (RealIP, error) {
|
||||
var r RealIP
|
||||
cfg, err := ParseFile(realIPConf)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return r, nil
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
mode := cfg.Value("useIpInProxyHeader")
|
||||
r.Enabled = mode != "" && mode != "0"
|
||||
if b := cfg.Block("accessControl"); b != nil {
|
||||
for item := range strings.SplitSeq(b.Value("allow"), ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || strings.EqualFold(item, "ALL") {
|
||||
continue
|
||||
}
|
||||
r.Trusted = append(r.Trusted, strings.TrimRight(item, "tT"))
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// SetRealIP 写入配置:有可信列表时只信任列表来源(列表项带 T 后缀),否则信任所有来源
|
||||
func SetRealIP(r RealIP) error {
|
||||
cfg := &Config{}
|
||||
switch {
|
||||
case !r.Enabled:
|
||||
cfg.Add("useIpInProxyHeader", "0")
|
||||
case len(r.Trusted) == 0:
|
||||
cfg.Add("useIpInProxyHeader", "1")
|
||||
default:
|
||||
cfg.Add("useIpInProxyHeader", "2")
|
||||
allow := []string{"ALL"}
|
||||
for _, ip := range r.Trusted {
|
||||
allow = append(allow, ip+"T")
|
||||
}
|
||||
cfg.AddBlock("accessControl", "").Add("allow", strings.Join(allow, ", "))
|
||||
}
|
||||
return os.WriteFile(realIPConf, []byte(cfg.String()), 0600)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ type Features struct {
|
||||
IPv6Listen bool // 站点额外监听 IPv6 地址
|
||||
Stat bool // 访问统计
|
||||
DefaultSite bool // 默认站点切换
|
||||
LSCache bool // LiteSpeed 页面缓存
|
||||
}
|
||||
|
||||
// Dialect 收敛某种 Web 服务器在面板层面的全部差异,新增服务器只需实现此接口并在 webserver 包中注册
|
||||
@@ -29,6 +30,8 @@ type Dialect interface {
|
||||
PHPCacheConf() string
|
||||
// SPAConf 静态站点单页应用路由回退片段
|
||||
SPAConf() string
|
||||
// LSCacheConf 站点级 LiteSpeed 页面缓存片段,name 为站点名
|
||||
LSCacheConf(name string) string
|
||||
// HTPasswdLine 基本认证 htpasswd 单行
|
||||
HTPasswdLine(username, password string) string
|
||||
// RewritesDir 伪静态预置目录名,语法相同的服务器可共用
|
||||
@@ -40,12 +43,13 @@ type Dialect interface {
|
||||
NewPHPVhost(configDir string) (PHPVhost, error)
|
||||
NewProxyVhost(configDir string) (ProxyVhost, error)
|
||||
|
||||
// 以下四个方法的 bool 返回值表示配置是否变化、是否需要重载 Web 服务器
|
||||
// WriteSiteChallenge 向网站 acme 配置文件投放一个 HTTP-01 验证
|
||||
WriteSiteChallenge(conf, path, token string) error
|
||||
WriteSiteChallenge(conf, path, token string) (bool, error)
|
||||
// RemoveSiteChallenge 移除网站 acme 配置文件中的一个 HTTP-01 验证
|
||||
RemoveSiteChallenge(conf, path, token string) error
|
||||
RemoveSiteChallenge(conf, path, token string) (bool, error)
|
||||
// WritePanelChallenge 写入面板独立验证站点,用于 80 端口已被 Web 服务器占用时签发面板证书
|
||||
WritePanelChallenge(conf string, names []string, tokens map[string]string) error
|
||||
WritePanelChallenge(conf string, names []string, tokens map[string]string) (bool, error)
|
||||
// RemovePanelChallenge 清理面板独立验证站点
|
||||
RemovePanelChallenge(conf string) error
|
||||
RemovePanelChallenge(conf string) (bool, error)
|
||||
}
|
||||
|
||||
@@ -16,4 +16,8 @@ export default {
|
||||
// 切换 PHP 版本运行协议
|
||||
setPHP: (version: number, lsapi: boolean): any =>
|
||||
http.Post('/apps/openlitespeed/php', { version, lsapi }),
|
||||
// 服务器级真实 IP 配置
|
||||
realIP: (): any => http.Get('/apps/openlitespeed/realip'),
|
||||
// 保存服务器级真实 IP 配置
|
||||
setRealIP: (data: any): any => http.Post('/apps/openlitespeed/realip', data),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface WebServerFeatures {
|
||||
defaultSite: boolean // 默认站点
|
||||
rateLimit: boolean // 限流限速
|
||||
realIP: boolean // 真实 IP
|
||||
lsCache: boolean // LiteSpeed 页面缓存
|
||||
upstreamAlgos: string[] // 上游负载均衡算法,空字符串为默认轮询
|
||||
lang: string // 配置文件语法高亮
|
||||
}
|
||||
@@ -31,6 +32,7 @@ const features: Record<string, WebServerFeatures> = {
|
||||
defaultSite: true,
|
||||
rateLimit: true,
|
||||
realIP: true,
|
||||
lsCache: false,
|
||||
upstreamAlgos: ['', 'least_conn', 'ip_hash', 'hash', 'random'],
|
||||
lang: 'nginx',
|
||||
},
|
||||
@@ -46,6 +48,7 @@ const features: Record<string, WebServerFeatures> = {
|
||||
defaultSite: false,
|
||||
rateLimit: true,
|
||||
realIP: true,
|
||||
lsCache: false,
|
||||
upstreamAlgos: ['', 'bybusyness', 'bytraffic'],
|
||||
lang: 'apacheconf',
|
||||
},
|
||||
@@ -61,6 +64,7 @@ const features: Record<string, WebServerFeatures> = {
|
||||
defaultSite: false,
|
||||
rateLimit: false,
|
||||
realIP: false,
|
||||
lsCache: true,
|
||||
upstreamAlgos: [''],
|
||||
lang: 'plaintext',
|
||||
},
|
||||
@@ -78,6 +82,7 @@ const unknown: WebServerFeatures = {
|
||||
defaultSite: false,
|
||||
rateLimit: false,
|
||||
realIP: false,
|
||||
lsCache: false,
|
||||
upstreamAlgos: [''],
|
||||
lang: 'plaintext',
|
||||
}
|
||||
|
||||
@@ -26,6 +26,32 @@ const { data: load } = useRequest(openlitespeed.load, {
|
||||
const { data: phpList, send: fetchPHP } = useRequest(openlitespeed.php, {
|
||||
initialData: [],
|
||||
})
|
||||
const { data: realIP } = useRequest(openlitespeed.realIP, {
|
||||
initialData: { enabled: false, trusted: [] },
|
||||
})
|
||||
const realIPLoading = ref(false)
|
||||
|
||||
// 可信代理列表,多行文本与数组双向转换
|
||||
const realIPTrusted = computed({
|
||||
get: () => (realIP.value.trusted ?? []).join('\n'),
|
||||
set: (value: string) => {
|
||||
realIP.value.trusted = value
|
||||
.split('\n')
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line !== '')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSaveRealIP = () => {
|
||||
realIPLoading.value = true
|
||||
useRequest(openlitespeed.setRealIP(realIP.value))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
realIPLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
@@ -161,6 +187,40 @@ const handleClearErrorLog = () => {
|
||||
<n-data-table striped remote :scroll-x="400" :columns="phpColumns" :data="phpList" />
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="realip" :tab="$gettext('Real IP')">
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">
|
||||
{{
|
||||
$gettext(
|
||||
'OpenLiteSpeed reads the client IP from the X-Forwarded-For header at the server level only, so this setting applies to all websites. Fill in the trusted proxy IPs (e.g., CDN or Frp); leave it empty to trust every source [insecure].',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-form-item :label="$gettext('Enable')">
|
||||
<n-switch v-model:value="realIP.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="realIP.enabled" :label="$gettext('IP Sources')">
|
||||
<n-input
|
||||
v-model:value="realIPTrusted"
|
||||
type="textarea"
|
||||
:placeholder="$gettext('One per line, e.g., 127.0.0.1 or 10.0.0.0/8')"
|
||||
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="realIPLoading"
|
||||
:disabled="realIPLoading"
|
||||
@click="handleSaveRealIP"
|
||||
>
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="load" :tab="$gettext('Load Status')">
|
||||
<n-data-table
|
||||
striped
|
||||
|
||||
@@ -2000,6 +2000,22 @@ const removeCustomConfig = (index: number) => {
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
|
||||
<!-- LiteSpeed 页面缓存 -->
|
||||
<n-collapse-item v-if="features.lsCache" title="LSCache" name="lscache_settings">
|
||||
<n-alert type="info" mb-4>
|
||||
{{
|
||||
$gettext(
|
||||
'Enables the LiteSpeed cache module for this site. Pages are cached only when the application asks for it, e.g. WordPress with the LiteSpeed Cache plugin.',
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
<n-form-item :label="$gettext('Enable LSCache')">
|
||||
<n-switch v-model:value="setting.lscache" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
|
||||
<!-- 日志设置 -->
|
||||
<n-collapse-item :title="$gettext('Log Settings')" name="log_settings">
|
||||
<n-form label-placement="left" label-width="140px">
|
||||
|
||||
Reference in New Issue
Block a user