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:
耗子
2026-09-14 19:10:00 +08:00
co-authored by Claude Fable 5.1
parent 7aa362c82f
commit 6158381b53
15 changed files with 322 additions and 58 deletions
+33
View File
@@ -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)
+6
View File
@@ -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"`
+13
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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"` // 基本认证规则
+20 -16
View File
@@ -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 路径的最后一段
+18 -14
View File
@@ -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 片段
+54 -15
View File
@@ -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 从 keyAuthtoken.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
}
+59
View File
@@ -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)
}
+8 -4
View File
@@ -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)
}
+4
View File
@@ -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),
}
+5
View File
@@ -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
+16
View File
@@ -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">