From 6158381b531c80f754edf9bf520dbbfc0e534ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Mon, 14 Sep 2026 19:10:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(openlitespeed):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=BA=A7=E7=9C=9F=E5=AE=9E=20IP=E3=80=81=E7=AB=99?= =?UTF-8?q?=E7=82=B9=20LSCache=20=E5=BC=80=E5=85=B3=E4=B8=8E=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=85=8D=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OLS 设置页新增真实 IP 标签,写入 conf/panel/realip.conf:有可信代理列表时 只信任列表来源,留空则信任所有来源 - 站点高级设置新增 LSCache 开关,写入 vhost 级 module cache 片段,缓存目录按站点隔离 - HTTP-01 投放与清理返回配置是否变化,求解器据此跳过空重载;OLS 探测运行中 服务是否已按当前指纹应答,命中即不重载 Co-Authored-By: Claude Fable 5.1 --- internal/apps/openlitespeed/app.go | 33 +++++++++ internal/apps/openlitespeed/request.go | 6 ++ internal/data/website.go | 13 ++++ internal/request/website.go | 1 + pkg/acme/solvers.go | 33 ++++++--- pkg/types/website.go | 1 + pkg/webserver/apache/dialect.go | 36 +++++----- pkg/webserver/nginx/dialect.go | 32 +++++---- pkg/webserver/openlitespeed/dialect.go | 69 +++++++++++++++---- pkg/webserver/openlitespeed/realip.go | 59 ++++++++++++++++ pkg/webserver/types/dialect.go | 12 ++-- web/src/api/apps/openlitespeed/index.ts | 4 ++ web/src/utils/webserver.ts | 5 ++ .../views/apps/openlitespeed/IndexView.vue | 60 ++++++++++++++++ web/src/views/website/EditModal.vue | 16 +++++ 15 files changed, 322 insertions(+), 58 deletions(-) create mode 100644 pkg/webserver/openlitespeed/realip.go diff --git a/internal/apps/openlitespeed/app.go b/internal/apps/openlitespeed/app.go index 3e62ded09..d84878897 100644 --- a/internal/apps/openlitespeed/app.go +++ b/internal/apps/openlitespeed/app.go @@ -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) diff --git a/internal/apps/openlitespeed/request.go b/internal/apps/openlitespeed/request.go index c0d95d494..21be93816 100644 --- a/internal/apps/openlitespeed/request.go +++ b/internal/apps/openlitespeed/request.go @@ -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"` diff --git a/internal/data/website.go b/internal/data/website.go index 7f5e98877..8e0fe8f7b 100644 --- a/internal/data/website.go +++ b/internal/data/website.go @@ -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 { diff --git a/internal/request/website.go b/internal/request/website.go index 45cc98216..ffb95145d 100644 --- a/internal/request/website.go +++ b/internal/request/website.go @@ -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"` // 限流限速配置 diff --git a/pkg/acme/solvers.go b/pkg/acme/solvers.go index 031bebd17..69291bb5f 100644 --- a/pkg/acme/solvers.go +++ b/pkg/acme/solvers.go @@ -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() diff --git a/pkg/types/website.go b/pkg/types/website.go index adc403845..22111af1b 100644 --- a/pkg/types/website.go +++ b/pkg/types/website.go @@ -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"` // 基本认证规则 diff --git a/pkg/webserver/apache/dialect.go b/pkg/webserver/apache/dialect.go index 46bd04e5e..f0a566a6a 100644 --- a/pkg/webserver/apache/dialect.go +++ b/pkg/webserver/apache/dialect.go @@ -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("\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 路径的最后一段 diff --git a/pkg/webserver/nginx/dialect.go b/pkg/webserver/nginx/dialect.go index 3560cba08..e382c81d6 100644 --- a/pkg/webserver/nginx/dialect.go +++ b/pkg/webserver/nginx/dialect.go @@ -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 片段 diff --git a/pkg/webserver/openlitespeed/dialect.go b/pkg/webserver/openlitespeed/dialect.go index 0dcad12de..92862a1ab 100644 --- a/pkg/webserver/openlitespeed/dialect.go +++ b/pkg/webserver/openlitespeed/dialect.go @@ -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.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 +} diff --git a/pkg/webserver/openlitespeed/realip.go b/pkg/webserver/openlitespeed/realip.go new file mode 100644 index 000000000..80cf3d159 --- /dev/null +++ b/pkg/webserver/openlitespeed/realip.go @@ -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) +} diff --git a/pkg/webserver/types/dialect.go b/pkg/webserver/types/dialect.go index f06ca39c4..520ba6432 100644 --- a/pkg/webserver/types/dialect.go +++ b/pkg/webserver/types/dialect.go @@ -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) } diff --git a/web/src/api/apps/openlitespeed/index.ts b/web/src/api/apps/openlitespeed/index.ts index 8850ad12a..a189fd4a0 100644 --- a/web/src/api/apps/openlitespeed/index.ts +++ b/web/src/api/apps/openlitespeed/index.ts @@ -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), } diff --git a/web/src/utils/webserver.ts b/web/src/utils/webserver.ts index 68a37795c..b5ad1a14e 100644 --- a/web/src/utils/webserver.ts +++ b/web/src/utils/webserver.ts @@ -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 = { defaultSite: true, rateLimit: true, realIP: true, + lsCache: false, upstreamAlgos: ['', 'least_conn', 'ip_hash', 'hash', 'random'], lang: 'nginx', }, @@ -46,6 +48,7 @@ const features: Record = { defaultSite: false, rateLimit: true, realIP: true, + lsCache: false, upstreamAlgos: ['', 'bybusyness', 'bytraffic'], lang: 'apacheconf', }, @@ -61,6 +64,7 @@ const features: Record = { 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', } diff --git a/web/src/views/apps/openlitespeed/IndexView.vue b/web/src/views/apps/openlitespeed/IndexView.vue index 665c0c78a..d498f192e 100644 --- a/web/src/views/apps/openlitespeed/IndexView.vue +++ b/web/src/views/apps/openlitespeed/IndexView.vue @@ -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 = () => { + + + + {{ + $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].', + ) + }} + + + + + + + + + + + + {{ $gettext('Save') }} + + + + { + + + + {{ + $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.', + ) + }} + + + + + + + +