refactor(webserver): 把方言间重复的机制收到共享层

前端的算法词表还列着各方言的原生名,后端统一成 nginx 写法后,选了会被静默降级成轮询。

- conf 补深拷贝,Apache 拆 VirtualHost 不用再渲染再解析两遍
- location 匹配类型、nginx 变量与占位符的双向映射、htpasswd 转加密文件上提到 types,
  四个方言各写一份已经开始分叉:Apache 的匹配只认带空格的操作符,而 nginx 允许 =/health;
  OLS 的 extraHeaders 走 Apache 语法却没做变量映射,Apache 刚修好的 bug 它完整留着
- htpasswd 的 bcrypt 并行算,重建全部站点时几百个哈希串起来要几十秒
- Apache 的代理地址改用 net/url,手写切分在 http://unix:/path.sock 上会拼错
- Apache 的 SNI 改用与 location、pass 一致的 ace: 元数据,不再自造一套注释格式
- 全局 Listen 改为整份重写面板独占文件,对齐 OLS 的做法,去掉标记区间与它的静默失效分支
- 防篡改重扫先判停再扫,挂载点预排序,省掉每路径每挂载点一次字符串拼接

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-09-21 00:41:37 +08:00
co-authored by Claude Opus 5
parent af67ccd9fc
commit e2c9b7efbb
20 changed files with 509 additions and 437 deletions
+34 -24
View File
@@ -30,6 +30,11 @@ type fileEntry struct {
exts []string // 目录条目的规则扩展名,空=整树
}
// sameObject 指向同一个内核对象,保护集合按 (dev, inode) 下发,路径相同但 inode 变了就是另一个对象
func (e fileEntry) sameObject(other fileEntry) bool {
return e.dev == other.dev && e.inode == other.inode
}
type fileKey struct {
dev uint64
inode uint64
@@ -99,8 +104,9 @@ func NewManager(cfg Config, log *slog.Logger) (*Manager, error) {
}
type mountDev struct {
point string
dev uint64
point string
prefix string // 带尾斜杠的挂载点,逐路径比较时不必反复拼
dev uint64
}
var mountUnescape = strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
@@ -155,22 +161,22 @@ func loadMountDevs() []mountDev {
if err1 != nil || err2 != nil {
continue
}
mounts = append(mounts, mountDev{point: mountUnescape.Replace(f[4]), dev: maj<<20 | minNum&0xfffff})
point := mountUnescape.Replace(f[4])
mounts = append(mounts, mountDev{point: point, prefix: strings.TrimRight(point, "/") + "/", dev: maj<<20 | minNum&0xfffff})
}
// 按挂载点由深到浅,查找时首个命中即最深的那个
slices.SortFunc(mounts, func(a, b mountDev) int { return len(b.point) - len(a.point) })
return mounts
}
// 同点位后挂载遮蔽先挂载,故最长前缀并列取靠后者
func devOfPath(mounts []mountDev, path string, st *syscall.Stat_t) uint64 {
best := -1
for i, m := range mounts {
if UnderRoot(path, m.point) && (best < 0 || len(m.point) >= len(mounts[best].point)) {
best = i
for _, m := range mounts {
if path == m.point || strings.HasPrefix(path, m.prefix) {
return m.dev
}
}
if best >= 0 {
return mounts[best].dev
}
return uint64(unix.Major(st.Dev))<<20 | uint64(unix.Minor(st.Dev))&0xfffff
}
@@ -305,32 +311,36 @@ func (m *Manager) rescanLoop() {
}
}
// rescan 重新扫描规则路径,把新增与消失的对象同步给引擎
func (m *Manager) rescan() {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
if !running {
return
}
refreshMountDevs()
current := make(map[string]fileEntry)
for _, e := range m.scan() {
entries := m.scan()
current := make(map[string]fileEntry, len(entries))
for _, e := range entries {
current[e.path] = e
}
m.mu.RLock()
running := m.running
var added, removed []fileEntry
if running {
for path, e := range current {
if old, ok := m.entries[path]; !ok || old.dev != e.dev || old.inode != e.inode {
added = append(added, e)
}
for path, e := range current {
if old, ok := m.entries[path]; !ok || !old.sameObject(e) {
added = append(added, e)
}
for path, e := range m.entries {
if c, ok := current[path]; !ok || c.dev != e.dev || c.inode != e.inode {
removed = append(removed, e)
}
}
for path, e := range m.entries {
if c, ok := current[path]; !ok || !c.sameObject(e) {
removed = append(removed, e)
}
}
m.mu.RUnlock()
if !running || (len(added) == 0 && len(removed) == 0) {
if len(added) == 0 && len(removed) == 0 {
return
}
+3
View File
@@ -15,6 +15,9 @@ const SitesPath = "/opt/ace/sites"
// ConfigName 站点配置文件名
const ConfigName = "apache.conf"
// ListenConf 面板维护的全局监听配置,放在 Apache 的 conf/extra 下
const ListenConf = "ace-listen.conf"
// HSTSValue 是 HSTS 响应头的默认 max-age(1 年)
const HSTSValue = "max-age=31536000"
+20 -37
View File
@@ -118,24 +118,13 @@ func (Dialect) RewritesDir() string {
return "apache"
}
// BeforeReload 同步全局监听端口
func (Dialect) BeforeReload() error {
return syncListen()
return syncListen(SitesPath, filepath.Join(ServerRoot, "conf"))
}
// listenBeginMark 主配置里由面板维护的监听区间标记
const (
listenBeginMark = "# AcePanel listen begin"
listenEndMark = "# AcePanel listen end"
)
// syncListen 站点用到的非标端口要在全局声明,Apache 不像 nginx 在站点块里自带监听,
// 少了全局 Listen 站点就完全不可达;同一端口重复声明又会让 Apache 起不来,所以整段一起重写
func syncListen() error {
return syncListenIn(SitesPath, filepath.Join(ServerRoot, "conf", "httpd.conf"))
}
func syncListenIn(sitesPath, mainConf string) error {
// syncListen 站点用到的非标端口要在全局声明,Apache 不像 nginx 在站点块里自带监听,少了全局 Listen 站点就完全不可达;
// 同一端口重复声明又会让 Apache 起不来,所以整份重写一个面板独占文件,主配置里只保证有一行引用它
func syncListen(sitesPath, confDir string) error {
ports := make(map[string]bool)
confs, _ := filepath.Glob(filepath.Join(sitesPath, "*", "config", ConfigName))
for _, path := range confs {
@@ -153,35 +142,29 @@ func syncListenIn(sitesPath, mainConf string) error {
}
}
lines := []string{"# Auto-generated by AcePanel. DO NOT EDIT MANUALLY!"}
for _, port := range slices.Sorted(maps.Keys(ports)) {
lines = append(lines, "Listen "+port)
}
if err := os.WriteFile(filepath.Join(confDir, "extra", ListenConf), []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil {
return fmt.Errorf("failed to write listen config: %w", err)
}
return ensureListenInclude(filepath.Join(confDir, "httpd.conf"))
}
// ensureListenInclude 老版本装出来的主配置里没有这行,补一次
func ensureListenInclude(mainConf string) error {
include := "IncludeOptional conf/extra/" + ListenConf
raw, err := os.ReadFile(mainConf)
if err != nil {
return fmt.Errorf("failed to read apache config: %w", err)
}
var block string
if len(ports) > 0 {
lines := []string{listenBeginMark}
for _, port := range slices.Sorted(maps.Keys(ports)) {
lines = append(lines, "Listen "+port)
}
block = strings.Join(append(lines, listenEndMark, ""), "\n")
}
content := string(raw)
if begin := strings.Index(content, listenBeginMark); begin >= 0 {
end := strings.Index(content, listenEndMark)
if end < begin {
return nil
}
content = strings.TrimRight(content[:begin], "\n") + "\n" + strings.TrimLeft(content[end+len(listenEndMark):], "\n")
}
if block != "" {
content = strings.TrimRight(content, "\n") + "\n\n" + block
}
if content == string(raw) {
if strings.Contains(string(raw), include) {
return nil
}
content := strings.TrimRight(string(raw), "\n") + "\n" + include + "\n"
if err = os.WriteFile(mainConf, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write apache config: %w", err)
}
+16
View File
@@ -256,3 +256,19 @@ func TestAddDirectiveAutoQuote(t *testing.T) {
check.Contains(t, out, "DocumentRoot /var/www")
check.NotContains(t, out, `"`)
}
// collectComments 递归收集节点树中的所有注释
func collectComments(nodes []conf.Node) []*conf.Comment {
var out []*conf.Comment
for _, n := range nodes {
switch v := n.(type) {
case *conf.Comment:
out = append(out, v)
case *conf.Directive:
if v.Block != nil {
out = append(out, collectComments(v.Nodes)...)
}
}
}
return out
}
+133 -167
View File
@@ -3,6 +3,7 @@ package apache
import (
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -104,10 +105,11 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
Replaces: make(map[string]string),
}
// 匹配类型与目标路径在 Apache 语法里不可逆,回读取写入时留下的原值
// 取写入时留下的原值
if blk := cfg.GetBlock("IfModule", "mod_proxy.c"); blk != nil {
proxy.Location = blk.Meta("location")
proxy.Pass = blk.Meta("pass")
proxy.SNI = blk.Meta("sni")
}
if proxy.Location == "" {
if d := cfg.FindOne("IfModule.ProxyPass"); d != nil && len(d.Args) >= 2 {
@@ -145,8 +147,6 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
proxy.Host = "$host"
}
proxy.SNI = findSNIComment(cfg)
if cfg.FindOne("IfModule.ProxyIOBufferSize") != nil {
proxy.Buffering = true
}
@@ -178,32 +178,6 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
return proxy, nil
}
// findSNIComment 从片段所有注释中提取 SNI 值
func findSNIComment(c *conf.Config) string {
for _, cmt := range collectComments(c.Nodes) {
if rest, ok := strings.CutPrefix(strings.TrimSpace(cmt.Text), "SNI:"); ok {
return strings.TrimSpace(rest)
}
}
return ""
}
// collectComments 递归收集节点树中的所有注释
func collectComments(nodes []conf.Node) []*conf.Comment {
var out []*conf.Comment
for _, n := range nodes {
switch v := n.(type) {
case *conf.Comment:
out = append(out, v)
case *conf.Directive:
if v.Block != nil {
out = append(out, collectComments(v.Nodes)...)
}
}
}
return out
}
// parseCacheBlock 从 mod_cache 块提取缓存配置
func parseCacheBlock(blk *conf.Directive) *types.CacheConfig {
cache := &types.CacheConfig{
@@ -297,32 +271,23 @@ type location struct {
path string // 用于 ProxyPassReverse 与 <Location> 的路径前缀
}
// parseLocation 把 nginx 的匹配前缀翻译成 Apache 的匹配方式
func parseLocation(loc string) location {
loc = strings.TrimSpace(loc)
switch {
case strings.HasPrefix(loc, "= "):
path := normalizePath(strings.TrimPrefix(loc, "= "))
kind, rest := types.ParseLocation(loc)
switch kind {
case types.LocationExact:
path := types.NormalizePath(rest)
return location{regex: true, pattern: "^" + regexp.QuoteMeta(path) + "$", path: path}
case strings.HasPrefix(loc, "~* "):
case types.LocationRegexAny:
// PCRE 的内联标志,ProxyPassMatch 本身区分大小写
return location{regex: true, pattern: "(?i)" + strings.TrimPrefix(loc, "~* "), path: "/"}
case strings.HasPrefix(loc, "~ "):
return location{regex: true, pattern: strings.TrimPrefix(loc, "~ "), path: "/"}
return location{regex: true, pattern: "(?i)" + rest, path: "/"}
case types.LocationRegex:
return location{regex: true, pattern: rest, path: "/"}
default:
path := normalizePath(strings.TrimPrefix(loc, "^~ "))
path := types.NormalizePath(rest)
return location{pattern: path, path: path}
}
}
func normalizePath(path string) string {
path = strings.TrimSpace(path)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
// proxyTarget 代理目标。nginx 的 proxy_pass 不带路径时保留完整 URI,带路径时替换掉 location 前缀,
// 而 Apache 的 ProxyPass 总是替换前缀,所以不带路径的要把 location 拼回去;
// ProxyPassMatch 会把整个 URI 附到目标后面,目标只能写到端口
@@ -342,87 +307,60 @@ func proxyTarget(pass string, loc location) string {
return strings.TrimSuffix(target, "/")
}
// nginx 变量到 Apache 表达式的映射,照搬会把 $remote_addr 这类变量当字面量发给上游
var headerVars = []struct{ from, to string }{
{"$proxy_add_x_forwarded_for", "%{HTTP:X-Forwarded-For}"},
{"$http_host", "%{HTTP_HOST}"},
{"$remote_addr", "%{REMOTE_ADDR}"},
{"$request_uri", "%{REQUEST_URI}"},
{"$server_name", "%{SERVER_NAME}"},
{"$server_port", "%{SERVER_PORT}"},
{"$scheme", "%{REQUEST_SCHEME}"},
{"$host", "%{HTTP_HOST}"},
}
// headerValue 含变量的头值转成 Apache 的 expr 写法
// headerValue 含变量的头值要写成 expr 形式,Apache 才会求值
func headerValue(value string) string {
replaced := value
for _, v := range headerVars {
replaced = strings.ReplaceAll(replaced, v.from, v.to)
}
if replaced == value {
native := types.ApacheVars.ToNative(value)
if native == value {
return value
}
return "expr=" + replaced
return "expr=" + native
}
// parseHeaderValue 还原成面板的 nginx 变量写法
func parseHeaderValue(value string) string {
expr, ok := strings.CutPrefix(value, "expr=")
if !ok {
return value
}
for _, v := range headerVars {
expr = strings.ReplaceAll(expr, v.to, v.from)
}
return expr
return types.ApacheVars.ToNginx(expr)
}
// splitPass 拆出代理地址的源与路径部分
func splitPass(pass string) (string, string) {
scheme, rest, found := strings.Cut(pass, "://")
if !found {
func splitPass(pass string) (origin, path string) {
u, err := url.Parse(pass)
if err != nil || u.Host == "" {
return pass, ""
}
host, path, found := strings.Cut(rest, "/")
if !found {
return pass, ""
}
return scheme + "://" + host, "/" + path
return u.Scheme + "://" + u.Host, u.Path
}
// balancerPass 上游名换成 Apache 的 balancer 地址,照搬 nginx 的 http://<上游名> 会被当真实主机解析
func balancerPass(pass string, upstreams []string) string {
origin, path := splitPass(pass)
_, host, found := strings.Cut(origin, "://")
if !found || !slices.Contains(upstreams, host) {
u, err := url.Parse(pass)
if err != nil || !slices.Contains(upstreams, u.Hostname()) {
return pass
}
return "balancer://" + host + path
return "balancer://" + u.Hostname() + u.Path
}
// proxyParams ProxyPass 的 worker 参数
func proxyParams(p types.Proxy) []string {
var params []string
if p.Timeout != nil {
if p.Timeout.Connect > 0 {
params = append(params, fmt.Sprintf("connectiontimeout=%d", int(p.Timeout.Connect.Seconds())))
params = append(params, "connectiontimeout="+strconv.Itoa(int(p.Timeout.Connect.Seconds())))
}
if p.Timeout.Read > 0 {
params = append(params, fmt.Sprintf("timeout=%d", int(p.Timeout.Read.Seconds())))
params = append(params, "timeout="+strconv.Itoa(int(p.Timeout.Read.Seconds())))
}
}
if p.Retry != nil && p.Retry.Timeout > 0 {
params = append(params, fmt.Sprintf("retry=%d", int(p.Retry.Timeout.Seconds())))
params = append(params, "retry="+strconv.Itoa(int(p.Retry.Timeout.Seconds())))
}
// 2.4.47 起 mod_proxy_http 自带协议升级,不加这个参数 WebSocket 会被当普通请求
// 2.4.47 起协议升级由 mod_proxy_http 接管,但要显式开启,否则 WebSocket 被当普通请求
params = append(params, "upgrade=websocket")
return params
}
// parseProxyParams 从 worker 参数还原超时与重试
func parseProxyParams(dirs ...*conf.Directive) (*types.TimeoutConfig, *types.RetryConfig) {
var timeout *types.TimeoutConfig
timeout := &types.TimeoutConfig{}
var retry *types.RetryConfig
for _, d := range dirs {
for _, a := range d.ArgsFrom(2) {
@@ -433,38 +371,34 @@ func parseProxyParams(dirs ...*conf.Directive) (*types.TimeoutConfig, *types.Ret
}
switch key {
case "connectiontimeout":
if timeout == nil {
timeout = &types.TimeoutConfig{}
}
timeout.Connect = time.Duration(seconds) * time.Second
case "timeout":
if timeout == nil {
timeout = &types.TimeoutConfig{}
}
timeout.Read = time.Duration(seconds) * time.Second
case "retry":
retry = &types.RetryConfig{Timeout: time.Duration(seconds) * time.Second}
}
}
}
if *timeout == (types.TimeoutConfig{}) {
return nil, retry
}
return timeout, retry
}
// accessControlNodes IP 访问控制,deny 要包在 RequireAll 里才是"默认放行、排除名单"的语义
func accessControlNodes(ac *types.AccessControlConfig) []conf.Node {
switch {
case len(ac.Deny) == 0:
if len(ac.Deny) == 0 {
return []conf.Node{conf.Dir("Require", append([]string{"ip"}, ac.Allow...)...)}
default:
all := conf.Blk("RequireAll")
if len(ac.Allow) > 0 {
all.Append(conf.Dir("Require", append([]string{"ip"}, ac.Allow...)...))
} else {
all.Append(conf.Dir("Require", "all", "granted"))
}
all.Append(conf.Dir("Require", append([]string{"not", "ip"}, ac.Deny...)...))
return []conf.Node{all}
}
allow := conf.Dir("Require", "all", "granted")
if len(ac.Allow) > 0 {
allow = conf.Dir("Require", append([]string{"ip"}, ac.Allow...)...)
}
return []conf.Node{conf.Blk("RequireAll").Append(
allow,
conf.Dir("Require", append([]string{"not", "ip"}, ac.Deny...)...),
)}
}
func parseAccessControl(blk *conf.Directive) *types.AccessControlConfig {
@@ -519,6 +453,10 @@ func generateProxyConfig(proxy types.Proxy, upstreams []string) string {
// 匹配类型与目标路径在 Apache 语法里不可逆,留下原值供回读
inner.AddMeta("location", proxy.Location)
inner.AddMeta("pass", proxy.Pass)
// Apache 不支持自定义 SNI,记下来供换回其它服务器时使用
if proxy.SNI != "" {
inner.AddMeta("sni", proxy.SNI)
}
args := []string{loc.pattern, target}
// balancer 目标只认 balancer 参数,worker 参数得写在 BalancerMember 上
@@ -545,42 +483,14 @@ func generateProxyConfig(proxy types.Proxy, upstreams []string) string {
conf.Dir("RequestHeader", "set", "X-Forwarded-Proto", "expr=%{REQUEST_SCHEME}"),
)
if strings.HasPrefix(target, "https://") {
inner.Append(conf.Dir("SSLProxyEngine", "On"))
if proxy.SSLBackend != nil && proxy.SSLBackend.Verify {
inner.Append(conf.Dir("SSLProxyVerify", "require"))
if proxy.SSLBackend.TrustedCertificate != "" {
inner.Append(conf.Dir("SSLProxyCACertificateFile", proxy.SSLBackend.TrustedCertificate))
}
} else {
inner.Append(
conf.Dir("SSLProxyVerify", "none"),
conf.Dir("SSLProxyCheckPeerCN", "off"),
conf.Dir("SSLProxyCheckPeerName", "off"),
)
}
if proxy.SNI != "" {
// 垃圾 Apache 不支持自定义 SNI,写注释备注
inner.Append(conf.Cmt("SNI: " + proxy.SNI))
}
}
inner.Append(sslProxyNodes(proxy, target)...)
if proxy.Buffering {
inner.Append(conf.Dir("ProxyIOBufferSize", "65536"))
}
if proxy.Cache != nil {
expireSeconds := 600
for _, duration := range proxy.Cache.Valid {
expireSeconds = parseDurationToSeconds(duration)
break
}
inner.Append(conf.Blk("IfModule", "mod_cache.c").Append(
conf.Dir("CacheEnable", "disk", loc.path),
conf.Dir("CacheDefaultExpire", strconv.Itoa(expireSeconds)),
))
inner.Append(cacheNode(proxy.Cache, loc.path))
}
if len(proxy.Headers) > 0 {
headers := conf.Blk("IfModule", "mod_headers.c")
for _, name := range slices.Sorted(maps.Keys(proxy.Headers)) {
@@ -588,18 +498,9 @@ func generateProxyConfig(proxy types.Proxy, upstreams []string) string {
}
inner.Append(headers)
}
if len(proxy.Replaces) > 0 {
sub := conf.Blk("IfModule", "mod_substitute.c").Append(
conf.Dir("AddOutputFilterByType", "SUBSTITUTE", "text/html", "text/plain", "text/xml"),
)
for _, from := range slices.Sorted(maps.Keys(proxy.Replaces)) {
// 用 | 作为分隔符以支持含 / 的内容,强制双引号
sub.Append(&conf.Directive{Name: "Substitute", Args: []conf.Arg{dquote(fmt.Sprintf("s|%s|%s|n", from, proxy.Replaces[from]))}})
}
inner.Append(sub)
inner.Append(substituteNode(proxy.Replaces))
}
// 这几项是按路径生效的,必须落在容器块里
if scoped := scopedNodes(proxy); len(scoped) > 0 {
inner.Append(conf.Blk("Location", loc.path).Append(scoped...))
@@ -614,7 +515,50 @@ func generateProxyConfig(proxy types.Proxy, upstreams []string) string {
return Export(cfg) + "\n"
}
// scopedNodes 访问控制、响应头与请求体限制
func sslProxyNodes(proxy types.Proxy, target string) []conf.Node {
if !strings.HasPrefix(target, "https://") {
return nil
}
nodes := []conf.Node{conf.Dir("SSLProxyEngine", "On")}
if proxy.SSLBackend != nil && proxy.SSLBackend.Verify {
nodes = append(nodes, conf.Dir("SSLProxyVerify", "require"))
if proxy.SSLBackend.TrustedCertificate != "" {
nodes = append(nodes, conf.Dir("SSLProxyCACertificateFile", proxy.SSLBackend.TrustedCertificate))
}
return nodes
}
return append(nodes,
conf.Dir("SSLProxyVerify", "none"),
conf.Dir("SSLProxyCheckPeerCN", "off"),
conf.Dir("SSLProxyCheckPeerName", "off"),
)
}
func cacheNode(cache *types.CacheConfig, path string) *conf.Directive {
expire := 600
for _, duration := range cache.Valid {
expire = parseDurationToSeconds(duration)
break
}
return conf.Blk("IfModule", "mod_cache.c").Append(
conf.Dir("CacheEnable", "disk", path),
conf.Dir("CacheDefaultExpire", strconv.Itoa(expire)),
)
}
func substituteNode(replaces map[string]string) *conf.Directive {
sub := conf.Blk("IfModule", "mod_substitute.c").Append(
conf.Dir("AddOutputFilterByType", "SUBSTITUTE", "text/html", "text/plain", "text/xml"),
)
for _, from := range slices.Sorted(maps.Keys(replaces)) {
// 用 | 作为分隔符以支持含 / 的内容,强制双引号
sub.Append(&conf.Directive{Name: "Substitute", Args: []conf.Arg{dquote(fmt.Sprintf("s|%s|%s|n", from, replaces[from]))}})
}
return sub
}
func scopedNodes(p types.Proxy) []conf.Node {
var nodes []conf.Node
if p.AccessControl != nil && (len(p.AccessControl.Allow) > 0 || len(p.AccessControl.Deny) > 0) {
@@ -718,17 +662,21 @@ func parseBalancerFile(filePath string, name string) (*types.Upstream, error) {
return upstream, nil
}
// lbMethods 规范算法名到 Apache lbmethod 的映射,Apache 只有按请求数、按流量、按繁忙度三种
var lbMethods = map[string]string{
types.AlgoRoundRobin: "byrequests",
types.AlgoLeastConn: "bybusyness",
// lbMethod 规范算法名转 Apache 的 lbmethod,Apache 只有按请求数、按流量、按繁忙度三种,其余表达不了
func lbMethod(algo string) (string, bool) {
switch algo {
case types.AlgoRoundRobin:
return "byrequests", true
case types.AlgoLeastConn:
return "bybusyness", true
default:
return "byrequests", false
}
}
func algoFromLBMethod(method string) string {
for algo, m := range lbMethods {
if m == method {
return algo
}
if method == "bybusyness" {
return types.AlgoLeastConn
}
return types.AlgoRoundRobin
}
@@ -736,7 +684,6 @@ func algoFromLBMethod(method string) string {
// balancerMember 上游地址与参数转 Apache 语法:地址必须带协议前缀,
// 参数名与 nginx 完全不同,照搬过去会让 Apache 以 "unknown Worker parameter" 拒载
func balancerMember(addr, options string) []string {
// 2.4.47 起 mod_proxy_http 自带协议升级,不加这个参数 WebSocket 会被当普通请求
args := []string{balancerAddr(addr), "upgrade=websocket"}
for _, opt := range strings.Fields(options) {
key, value, _ := strings.Cut(opt, "=")
@@ -780,20 +727,39 @@ func parseMember(args []string) (string, string) {
continue
}
key, value, _ := strings.Cut(a, "=")
switch {
case key == "loadfactor":
switch key {
case "loadfactor":
options = append(options, "weight="+value)
case key == "retry":
case "retry":
options = append(options, "fail_timeout="+value+"s")
case a == "status=+H":
options = append(options, "backup")
case a == "status=+D":
options = append(options, "down")
case "status":
switch value {
case "+H":
options = append(options, "backup")
case "+D":
options = append(options, "down")
}
}
}
return addr, strings.Join(options, " ")
}
// balancerNames 上游名从文件名就能取到,不必读内容
func balancerNames(sharedDir string) []string {
entries, err := os.ReadDir(sharedDir)
if err != nil {
return nil
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
if matches := balancerFilePattern.FindStringSubmatch(entry.Name()); matches != nil {
names = append(names, matches[2])
}
}
return names
}
// writeBalancerFiles 将负载均衡配置写入文件
func writeBalancerFiles(sharedDir string, upstreams []types.Upstream) error {
if err := clearBalancerFiles(sharedDir); err != nil {
@@ -844,9 +810,9 @@ func generateBalancerConfig(upstream types.Upstream) string {
}
algo := types.NormalizeAlgo(upstream.Algo)
method, ok := lbMethods[algo]
method, ok := lbMethod(algo)
if !ok {
method = lbMethods[types.AlgoRoundRobin]
// 降级后靠注释保留原值,换回其它服务器时不丢
proxy.AddMeta("algo", algo)
}
proxy.Append(conf.Dir("ProxySet", "lbmethod="+method))
+28 -87
View File
@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/pkg/webserver/conf"
"github.com/acepanel/panel/v3/pkg/webserver/types"
@@ -76,6 +76,7 @@ func newBaseVhost(configDir string) (*baseVhost, error) {
// loadVhosts 把写盘时拆开的明文与 TLS 两个 VirtualHost 合回单一逻辑视图
func (v *baseVhost) loadVhosts() {
clear(v.httpsPorts)
vhosts := v.config.Blocks("VirtualHost")
if len(vhosts) == 0 {
v.vhost = v.config.AddBlock("VirtualHost", "*:80")
@@ -173,7 +174,7 @@ func (v *baseVhost) SetDefault(bool) error {
func (v *baseVhost) Listen() []types.Listen {
var result []types.Listen
// Apache 的监听写在 <VirtualHost *:80> 标签参数中,TLS 与否由块上的 SSLEngine 决定
// Apache 的监听写在 <VirtualHost *:80> 标签参数中,是否走 TLS 取自加载时记下的 httpsPorts
for _, addr := range v.vhost.Values() {
args := []string{}
if v.httpsPorts[portOf(addr)] {
@@ -323,12 +324,8 @@ func (v *baseVhost) SetErrorLog(errorLog string) error {
}
func (v *baseVhost) Save() error {
config, err := v.expand()
if err != nil {
return err
}
configFile := filepath.Join(v.configDir, ConfigName)
if err = os.WriteFile(configFile, []byte(Render(config)+"\n"), 0600); err != nil {
if err := os.WriteFile(configFile, []byte(Render(v.expand())+"\n"), 0600); err != nil {
return fmt.Errorf("failed to save config file: %w", err)
}
return nil
@@ -336,8 +333,8 @@ func (v *baseVhost) Save() error {
// expand TLS 端口与明文端口各占一个 VirtualHost。SSLEngine 按 VirtualHost 生效,
// 同一个块里混用两种端口会让明文端口直接断连,连强制跳转都执行不到
func (v *baseVhost) expand() (*conf.Config, error) {
var plainAddrs, tlsAddrs []string
func (v *baseVhost) expand() *conf.Config {
plainAddrs, tlsAddrs := []string{}, []string{}
for _, addr := range v.vhost.Values() {
if v.httpsPorts[portOf(addr)] {
tlsAddrs = append(tlsAddrs, addr)
@@ -346,34 +343,22 @@ func (v *baseVhost) expand() (*conf.Config, error) {
}
}
if len(plainAddrs) == 0 || len(tlsAddrs) == 0 {
return v.config, nil
return v.config
}
// 语法树没有深拷贝,渲染再解析得到独立副本
rendered := Render(v.config)
config, err := ParseString(rendered)
if err != nil {
return nil, fmt.Errorf("failed to split vhost: %w", err)
}
plain, err := ParseString(rendered)
if err != nil {
return nil, fmt.Errorf("failed to split vhost: %w", err)
}
tlsBlocks, plainBlocks := config.Blocks("VirtualHost"), plain.Blocks("VirtualHost")
if len(tlsBlocks) == 0 || len(plainBlocks) == 0 {
return v.config, nil
}
tlsBlocks[0].SetArgs(tlsAddrs...)
plainBlocks[0].SetArgs(plainAddrs...)
plainBlocks[0].Filter(func(n conf.Node) bool {
plain := v.vhost.Clone()
plain.SetArgs(plainAddrs...)
plain.Filter(func(n conf.Node) bool {
d, ok := n.(*conf.Directive)
return !ok || !strings.HasPrefix(d.Name, "SSL")
})
plainBlocks[0].RemoveFunc("Header", isHSTSHeader)
config.Append(&conf.Blank{}, plainBlocks[0])
plain.RemoveFunc("Header", isHSTSHeader)
return config, nil
config := &conf.Config{Block: *v.config.Clone()}
config.Blocks("VirtualHost")[0].SetArgs(tlsAddrs...)
config.Append(&conf.Blank{}, plain)
return config
}
func (v *baseVhost) Reset() error {
@@ -382,7 +367,6 @@ func (v *baseVhost) Reset() error {
return fmt.Errorf("failed to reset config: %w", err)
}
v.config = config
clear(v.httpsPorts)
v.loadVhosts()
return nil
}
@@ -500,25 +484,18 @@ func sslProtocolArgs(protocols []string) []string {
if len(protocols) == 0 {
protocols = []string{"TLSv1.2", "TLSv1.3"}
}
args := []string{"-all"}
for _, p := range protocols {
args = append(args, "+"+strings.TrimPrefix(p, "+"))
}
return args
return append([]string{"-all"}, lo.Map(protocols, func(p string, _ int) string {
return "+" + strings.TrimPrefix(p, "+")
})...)
}
// parseSSLProtocols 从 Apache 语法还原协议列表,只取显式开启的项
func parseSSLProtocols(args []string) []string {
protocols := make([]string, 0, len(args))
for _, a := range args {
if name, ok := strings.CutPrefix(a, "+"); ok {
protocols = append(protocols, name)
}
}
return protocols
return lo.FilterMap(args, func(a string, _ int) (string, bool) {
return strings.CutPrefix(a, "+")
})
}
// removeHTTPRedirect 移除面板写入的强制跳转规则
func (v *baseVhost) removeHTTPRedirect() {
v.vhost.RemoveFunc("RewriteCond", func(d *conf.Directive) bool {
return d.Arg(0) == "%{HTTPS}" && strings.EqualFold(d.Arg(1), "off")
@@ -607,6 +584,9 @@ func (v *baseVhost) BasicAuth() []types.BasicAuth {
return nil
}
// userFileSuffix 加密用户文件的后缀,与面板可回读的明文 htpasswd 并存
const userFileSuffix = ".apache"
func (v *baseVhost) SetBasicAuth(auths []types.BasicAuth) error {
_ = v.ClearBasicAuth()
// Location 块后声明者覆盖先声明者,按路径长度升序写出使更精确的目录规则生效;
@@ -615,7 +595,8 @@ func (v *baseVhost) SetBasicAuth(auths []types.BasicAuth) error {
return len(a.Path) - len(b.Path)
})
for _, auth := range auths {
if err := writeUserFile(auth.UserFile); err != nil {
// 缺文件时照写规则,Apache 找不到 AuthUserFile 会拒绝访问,比放行安全
if _, err := types.WriteUserFile(auth.UserFile, userFileSuffix, ":"); err != nil {
return err
}
v.vhost.AddBlock("Location", auth.Path).Append(
@@ -628,41 +609,6 @@ func (v *baseVhost) SetBasicAuth(auths []types.BasicAuth) error {
return nil
}
// userFileSuffix 加密用户文件的后缀,与面板可回读的明文 htpasswd 并存
const userFileSuffix = ".apache"
// writeUserFile Apache 在 Unix 下不认明文密码,明文 htpasswd 另转一份 bcrypt
func writeUserFile(userFile string) error {
content, err := os.ReadFile(userFile)
if err != nil {
// 用户文件缺失时不阻塞站点保存
return nil
}
var lines []string
for line := range strings.SplitSeq(string(content), "\n") {
user, password, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok || user == "" || strings.HasPrefix(user, "#") {
continue
}
password = strings.TrimPrefix(password, "{PLAIN}")
if strings.HasPrefix(password, "$") || strings.HasPrefix(password, "{SHA}") {
lines = append(lines, user+":"+password)
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
lines = append(lines, user+":"+string(hash))
}
if err = os.WriteFile(userFile+userFileSuffix, []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil {
return fmt.Errorf("failed to write user file: %w", err)
}
return nil
}
func (v *baseVhost) ClearBasicAuth() error {
// 清除旧版 vhost 级指令
for _, name := range []string{"AuthType", "AuthName", "AuthUserFile"} {
@@ -780,12 +726,7 @@ func (v *ProxyVhost) SetProxies(proxies []types.Proxy) error {
// upstreamNames 已写入的上游名,代理目标指向其中之一时要换成 balancer 地址
func (v *ProxyVhost) upstreamNames() []string {
upstreams, _ := parseBalancerFiles(filepath.Join(v.configDir, "shared"))
names := make([]string, 0, len(upstreams))
for _, up := range upstreams {
names = append(names, up.Name)
}
return names
return balancerNames(filepath.Join(v.configDir, "shared"))
}
func (v *ProxyVhost) ClearProxies() error {
+15 -11
View File
@@ -638,7 +638,7 @@ func TestProxyVhostSNI(t *testing.T) {
must.NoError(t, err)
check.Contains(t, string(content), "SSLProxyEngine On")
check.Contains(t, string(content), "# SNI: backend.example.com")
check.Contains(t, string(content), "# ace:sni backend.example.com")
check.DeepEqual(t, vhost.Proxies(), proxies, cmpopts.EquateEmpty())
}
@@ -718,8 +718,11 @@ func TestProxyVhostUpstreamMultipleServers(t *testing.T) {
func TestSyncListen(t *testing.T) {
root := t.TempDir()
mainConf := filepath.Join(root, "httpd.conf")
confDir := filepath.Join(root, "conf")
must.NoError(t, os.MkdirAll(filepath.Join(confDir, "extra"), 0755))
mainConf := filepath.Join(confDir, "httpd.conf")
must.NoError(t, os.WriteFile(mainConf, []byte("Listen 80\nListen 443\n"), 0644))
listenConf := filepath.Join(confDir, "extra", ListenConf)
newSite := func(name string, addrs ...string) {
dir := filepath.Join(root, "sites", name, "config")
@@ -729,25 +732,26 @@ func TestSyncListen(t *testing.T) {
}
sites := filepath.Join(root, "sites")
// 只有标准端口时不写入
// 只有标准端口时不写监听项,但引用行要补上
newSite("a", "*:80", "*:443")
must.NoError(t, syncListenIn(sites, mainConf))
check.Equal(t, readFile(t, mainConf), "Listen 80\nListen 443\n")
must.NoError(t, syncListen(sites, confDir))
check.NotContains(t, readFile(t, listenConf), "Listen")
check.Contains(t, readFile(t, mainConf), "IncludeOptional conf/extra/"+ListenConf)
// 非标端口去重后补齐
newSite("b", "*:8080", "[::]:8443")
newSite("c", "127.0.0.1:8080")
must.NoError(t, syncListenIn(sites, mainConf))
check.Contains(t, readFile(t, mainConf), listenBeginMark+"\nListen 8080\nListen 8443\n"+listenEndMark)
must.NoError(t, syncListen(sites, confDir))
check.Contains(t, readFile(t, listenConf), "Listen 8080\nListen 8443\n")
// 重复执行不累加
before := readFile(t, mainConf)
must.NoError(t, syncListenIn(sites, mainConf))
must.NoError(t, syncListen(sites, confDir))
check.Equal(t, readFile(t, mainConf), before)
// 端口不再使用时整段移除
// 端口不再使用时随之移除
must.NoError(t, os.RemoveAll(filepath.Join(sites, "b")))
must.NoError(t, os.RemoveAll(filepath.Join(sites, "c")))
must.NoError(t, syncListenIn(sites, mainConf))
check.Equal(t, readFile(t, mainConf), "Listen 80\nListen 443\n")
must.NoError(t, syncListen(sites, confDir))
check.NotContains(t, readFile(t, listenConf), "Listen")
}
+20 -46
View File
@@ -2,6 +2,7 @@ package caddy
import (
"fmt"
"maps"
"net"
"net/url"
"regexp"
@@ -28,7 +29,7 @@ func (v *baseVhost) buildUpstreams(cfg *conf.Config) {
for _, up := range v.upstreams {
s := cfg.AddBlock("(" + v.upstreamSnippet(up.Name) + ")")
s.AddMeta("upstream", up.Name)
servers := sortedKeys(up.Servers)
servers := slices.Sorted(maps.Keys(up.Servers))
to := make([]string, 0, len(servers))
weights := make([]string, 0, len(servers))
weighted := false
@@ -150,7 +151,7 @@ func (v *baseVhost) buildProxies(body *conf.Block) {
if prefix, path := passPrefixRewrite(p); path != "" {
h.Add("uri", "path_regexp", "^"+regexp.QuoteMeta(prefix), path)
}
for _, from := range sortedKeys(p.Replaces) {
for _, from := range slices.Sorted(maps.Keys(p.Replaces)) {
h.Add("replace", from, p.Replaces[from])
}
@@ -167,22 +168,22 @@ func (v *baseVhost) buildProxies(body *conf.Block) {
// 与 nginx 的默认值 $proxy_host 一致,发上游主机名而不是访客 Host
rp.Add("header_up", "Host", "{upstream_hostport}")
default:
rp.Add("header_up", "Host", caddyValue(host))
rp.Add("header_up", "Host", nginxVariables.ToNative(host))
}
// X-Forwarded-* 由 Caddy 自动附加,X-Real-IP 与 nginx 方言一样默认补上
if _, ok := p.Headers[realIPHeader]; !ok {
rp.Add("header_up", realIPHeader, realIPValue)
}
for _, name := range sortedKeys(p.Headers) {
for _, name := range slices.Sorted(maps.Keys(p.Headers)) {
// nginx 习惯手写的 X-Forwarded-For 链由 Caddy 自动维护,写死反而会丢掉上游链路
if strings.EqualFold(name, "X-Forwarded-For") && p.Headers[name] == "$proxy_add_x_forwarded_for" {
continue
}
rp.Add("header_up", name, caddyValue(p.Headers[name]))
rp.Add("header_up", name, nginxVariables.ToNative(p.Headers[name]))
}
if p.ResponseHeaders != nil {
for _, name := range sortedKeys(p.ResponseHeaders.Add) {
rp.Add("header_down", name, caddyValue(p.ResponseHeaders.Add[name]))
for _, name := range slices.Sorted(maps.Keys(p.ResponseHeaders.Add)) {
rp.Add("header_down", name, nginxVariables.ToNative(p.ResponseHeaders.Add[name]))
}
for _, name := range p.ResponseHeaders.Hide {
rp.Add("header_down", "-"+name)
@@ -337,7 +338,7 @@ func (v *baseVhost) loadProxy(h *conf.Directive) types.Proxy {
p.Host = strings.Replace(d.Arg(1), "{upstream_hostport}", "$proxy_host", 1)
case d.Arg(0) == realIPHeader && d.Arg(1) == realIPValue:
default:
p.Headers[d.Arg(0)] = nginxValue(d.Arg(1))
p.Headers[d.Arg(0)] = nginxVariables.ToNginx(d.Arg(1))
}
}
for _, d := range rp.GetAll("header_down") {
@@ -347,7 +348,7 @@ func (v *baseVhost) loadProxy(h *conf.Directive) types.Proxy {
if name, hide := strings.CutPrefix(d.Arg(0), "-"); hide {
p.ResponseHeaders.Hide = append(p.ResponseHeaders.Hide, name)
} else {
p.ResponseHeaders.Add[d.Arg(0)] = nginxValue(d.Arg(1))
p.ResponseHeaders.Add[d.Arg(0)] = nginxVariables.ToNginx(d.Arg(1))
}
}
if d := rp.Get("flush_interval"); d != nil && d.Arg(0) == "-1" {
@@ -436,40 +437,13 @@ func nginxAddress(addr string) string {
return addr
}
var nginxVariables = strings.NewReplacer(
"$proxy_add_x_forwarded_for", "{remote_host}",
"$remote_addr", "{remote_host}",
"$http_host", "{host}",
"$host", "{host}",
"$scheme", "{scheme}",
"$request_uri", "{uri}",
"$server_port", "{port}",
)
var caddyPlaceholders = strings.NewReplacer(
"{remote_host}", "$remote_addr",
"{host}", "$host",
"{scheme}", "$scheme",
"{uri}", "$request_uri",
"{port}", "$server_port",
)
// caddyValue 常见的 nginx 变量换成 Caddy 占位符,切换服务器后头部仍生效
func caddyValue(value string) string {
return nginxVariables.Replace(value)
}
// nginxValue 回读时换回 nginx 变量,否则切到别的方言会把 {scheme} 当字面量发给上游
func nginxValue(value string) string {
return caddyPlaceholders.Replace(value)
}
// sortedKeys 返回排序后的键,保证生成结果稳定
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// 表首命中即回读时的规范原像,所以 $host 要排在 $http_host 之前
var nginxVariables = types.NewVarMap([][2]string{
{"$proxy_add_x_forwarded_for", "{remote_host}"},
{"$remote_addr", "{remote_host}"},
{"$host", "{host}"},
{"$http_host", "{host}"},
{"$scheme", "{scheme}"},
{"$request_uri", "{uri}"},
{"$server_port", "{port}"},
})
+8 -25
View File
@@ -11,8 +11,6 @@ import (
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/acepanel/panel/v3/pkg/tools"
"github.com/acepanel/panel/v3/pkg/webserver/conf"
"github.com/acepanel/panel/v3/pkg/webserver/types"
@@ -418,37 +416,22 @@ func (v *baseVhost) Save() error {
return nil
}
// caddyUserFile 供 basic_auth import 的用户文件,与面板可回读的明文 htpasswd 并存
// userFileSuffix 供 basic_auth import 的用户文件后缀,与面板可回读的明文 htpasswd 并存
const userFileSuffix = ".caddy"
func caddyUserFile(userFile string) string {
return userFile + ".caddy"
return userFile + userFileSuffix
}
// writeUserFiles Caddy 只接受 bcrypt 或 argon2id,明文 htpasswd 另转一份;返回各文件是否有用户
// writeUserFiles 明文 htpasswd 另转一份 Caddy 能用的;返回各文件是否有用户
func (v *baseVhost) writeUserFiles() (map[string]bool, error) {
users := make(map[string]bool, len(v.auths))
for _, auth := range v.auths {
// 用户文件缺失时当作没有用户,规则不生成,不能阻塞整个站点保存
content, err := os.ReadFile(auth.UserFile)
hasUsers, err := types.WriteUserFile(auth.UserFile, userFileSuffix, " ")
if err != nil {
users[auth.UserFile] = false
continue
return nil, err
}
var lines []string
for line := range strings.SplitSeq(string(content), "\n") {
user, password, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok || user == "" || strings.HasPrefix(user, "#") {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(strings.TrimPrefix(password, "{PLAIN}")), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
lines = append(lines, user+" "+string(hash))
}
if err = os.WriteFile(caddyUserFile(auth.UserFile), []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil {
return nil, fmt.Errorf("failed to write user file: %w", err)
}
users[auth.UserFile] = len(lines) > 0
users[auth.UserFile] = hasUsers
}
return users, nil
}
+37
View File
@@ -2,6 +2,8 @@
// 各方言只负责自己的分词、解析与渲染,查询与修改在这里统一实现
package conf
import "slices"
// Quote 参数的引号风格,解析时记录原样,生成时由方言按 QuoteAuto 决定是否加引号
type Quote uint8
@@ -137,3 +139,38 @@ func (d *Directive) Append(nodes ...Node) *Directive {
d.Nodes = append(d.Nodes, nodes...)
return d
}
// Clone 深拷贝节点列表,副本与原树不共享任何可变结构
func (b *Block) Clone() *Block {
if b == nil {
return nil
}
out := &Block{Nodes: make([]Node, 0, len(b.Nodes))}
for _, n := range b.Nodes {
out.Nodes = append(out.Nodes, cloneNode(n))
}
return out
}
// Clone 深拷贝指令及其子块
func (d *Directive) Clone() *Directive {
if d == nil {
return nil
}
out := &Directive{Name: d.Name, Trailing: d.Trailing, Block: d.Block.Clone()}
if d.Args != nil {
out.Args = slices.Clone(d.Args)
}
return out
}
func cloneNode(n Node) Node {
switch v := n.(type) {
case *Directive:
return v.Clone()
case *Comment:
return &Comment{Text: v.Text}
default:
return &Blank{}
}
}
-10
View File
@@ -57,13 +57,3 @@ func writeFragment(path string, nodes ...conf.Node) error {
}
return nil
}
// sortedKeys 返回排序后的键,保证生成结果稳定
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+6 -4
View File
@@ -2,8 +2,10 @@ package nginx
import (
"fmt"
"maps"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -308,13 +310,13 @@ func proxyNode(p types.Proxy) *conf.Directive {
if p.Cache != nil {
addProxyCache(loc, p.Cache)
}
for _, name := range sortedKeys(p.Headers) {
for _, name := range slices.Sorted(maps.Keys(p.Headers)) {
loc.Add("proxy_set_header", name, p.Headers[name])
}
if len(p.Replaces) > 0 {
loc.Add("proxy_set_header", "Accept-Encoding", "")
loc.Add("sub_filter_once", "off")
for _, from := range sortedKeys(p.Replaces) {
for _, from := range slices.Sorted(maps.Keys(p.Replaces)) {
loc.Add("sub_filter", from, p.Replaces[from])
}
}
@@ -322,7 +324,7 @@ func proxyNode(p types.Proxy) *conf.Directive {
for _, header := range p.ResponseHeaders.Hide {
loc.Add("proxy_hide_header", header)
}
for _, name := range sortedKeys(p.ResponseHeaders.Add) {
for _, name := range slices.Sorted(maps.Keys(p.ResponseHeaders.Add)) {
loc.Add("add_header", name, p.ResponseHeaders.Add[name], "always")
}
}
@@ -335,7 +337,7 @@ func addProxyCache(loc *conf.Directive, cache *types.CacheConfig) {
loc.Add("proxy_cache_valid", "200", "302", "10m")
loc.Add("proxy_cache_valid", "404", "10s")
}
for _, codes := range sortedKeys(cache.Valid) {
for _, codes := range slices.Sorted(maps.Keys(cache.Valid)) {
if codes == "any" {
loc.Add("proxy_cache_valid", cache.Valid[codes])
} else {
+3 -1
View File
@@ -2,9 +2,11 @@ package nginx
import (
"fmt"
"maps"
"math"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
@@ -84,7 +86,7 @@ func upstreamNode(u types.Upstream) *conf.Directive {
up.Add("resolver_timeout", formatDuration(u.ResolverTimeout))
}
}
for _, addr := range sortedKeys(u.Servers) {
for _, addr := range slices.Sorted(maps.Keys(u.Servers)) {
up.Add("server", append([]string{addr}, strings.Fields(u.Servers[addr])...)...)
}
if u.Keepalive > 0 {
+25 -16
View File
@@ -2,9 +2,11 @@ package openlitespeed
import (
"fmt"
"maps"
"net"
"net/url"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -105,12 +107,12 @@ func (v *baseVhost) buildProxies(cfg *conf.Config) map[int]bool {
if host := proxyHost(p); host != "" {
headers = append(headers, "RequestHeader set Host "+host)
}
for _, name := range sortedKeys(p.Headers) {
headers = append(headers, fmt.Sprintf("RequestHeader set %s %s", name, p.Headers[name]))
for _, name := range slices.Sorted(maps.Keys(p.Headers)) {
headers = append(headers, fmt.Sprintf("RequestHeader set %s %s", name, headerValue(p.Headers[name])))
}
if p.ResponseHeaders != nil {
for _, name := range sortedKeys(p.ResponseHeaders.Add) {
headers = append(headers, fmt.Sprintf("Header set %s %s", name, p.ResponseHeaders.Add[name]))
for _, name := range slices.Sorted(maps.Keys(p.ResponseHeaders.Add)) {
headers = append(headers, fmt.Sprintf("Header set %s %s", name, headerValue(p.ResponseHeaders.Add[name])))
}
for _, name := range p.ResponseHeaders.Hide {
headers = append(headers, "Header unset "+name)
@@ -171,6 +173,23 @@ func proxyHost(p types.Proxy) string {
}
}
// headerValue 含变量的头值要写成 expr 形式,OLS 的 extraHeaders 走 Apache 指令语法,照搬 nginx 变量会当字面量发给上游
func headerValue(value string) string {
native := types.ApacheVars.ToNative(value)
if native == value {
return value
}
return "expr=" + native
}
func parseHeaderValue(value string) string {
expr, ok := strings.CutPrefix(value, "expr=")
if !ok {
return value
}
return types.ApacheVars.ToNginx(expr)
}
// upstreamHost 取代理地址里的主机名
func upstreamHost(pass string) string {
u, err := url.Parse(pass)
@@ -250,12 +269,12 @@ func (v *baseVhost) loadProxies(cfg *conf.Config) {
p.Host = m[4]
}
case m[1] == "RequestHeader":
p.Headers[m[3]] = m[4]
p.Headers[m[3]] = parseHeaderValue(m[4])
case m[2] == "set":
if p.ResponseHeaders == nil {
p.ResponseHeaders = &types.ResponseHeaderConfig{Add: make(map[string]string)}
}
p.ResponseHeaders.Add[m[3]] = m[4]
p.ResponseHeaders.Add[m[3]] = parseHeaderValue(m[4])
default:
if p.ResponseHeaders == nil {
p.ResponseHeaders = &types.ResponseHeaderConfig{Add: make(map[string]string)}
@@ -327,13 +346,3 @@ func addrFromOLS(addr string) string {
}
return addr
}
// sortedKeys 返回排序后的键,保证生成结果稳定
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+7 -3
View File
@@ -664,7 +664,7 @@ func (v *baseVhost) buildAuthContexts(cfg *conf.Config, consumed map[int]bool) {
// proxyHandlerFor 覆盖该路径的代理规则的处理器,按最长前缀匹配,正则规则不参与
func (v *baseVhost) proxyHandlerFor(path string) string {
path = "/" + strings.Trim(path, "/")
best, handler := -1, ""
best, match := -1, -1
for i, p := range v.proxies {
uri := locationToURI(p.Location)
if strings.HasPrefix(uri, "exp:") {
@@ -675,10 +675,14 @@ func (v *baseVhost) proxyHandlerFor(path string) string {
continue
}
if len(prefix) > best {
h, _ := v.proxyHandler(p, i)
best, handler = len(prefix), h
best, match = len(prefix), i
}
}
if match < 0 {
return ""
}
handler, _ := v.proxyHandler(v.proxies[match], match)
return handler
}
+58
View File
@@ -0,0 +1,58 @@
package types
import (
"fmt"
"os"
"strings"
lop "github.com/samber/lo/parallel"
"golang.org/x/crypto/bcrypt"
)
// WriteUserFile 把面板的明文 htpasswd 转写成方言能用的加密文件,返回里面是否有用户。
// 面板文件保持 {PLAIN} 明文以便回读,而 Apache 在 Unix 下不认明文密码、Caddy 只收 bcrypt 或 argon2id;
// 用户文件缺失时当作没有用户,不能阻塞整个站点保存
func WriteUserFile(userFile, suffix, sep string) (bool, error) {
content, err := os.ReadFile(userFile)
if err != nil {
return false, nil //nolint:nilerr // 用户文件缺失不能阻塞整个站点保存
}
type entry struct{ user, password string }
users := make([]entry, 0)
for line := range strings.SplitSeq(string(content), "\n") {
user, password, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok || user == "" || strings.HasPrefix(user, "#") {
continue
}
users = append(users, entry{user, strings.TrimPrefix(password, "{PLAIN}")})
}
// bcrypt 是故意设计得慢的纯 CPU 活,重建全部站点时几百个哈希串起来要几十秒
type hashed struct {
line string
err error
}
results := lop.Map(users, func(u entry, _ int) hashed {
// 已经是哈希的原样带过,用户可能直接手写了加密口令
if strings.HasPrefix(u.password, "$") || strings.HasPrefix(u.password, "{SHA}") {
return hashed{line: u.user + sep + u.password}
}
hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
return hashed{line: u.user + sep + string(hash), err: err}
})
lines := make([]string, 0, len(results))
for _, r := range results {
if r.err != nil {
return false, r.err
}
lines = append(lines, r.line)
}
if err = os.WriteFile(userFile+suffix, []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil {
return false, fmt.Errorf("failed to write user file: %w", err)
}
return len(lines) > 0, nil
}
+42
View File
@@ -0,0 +1,42 @@
package types
import "strings"
// LocationKind location 的匹配方式,面板统一用 nginx 的写法表达,各方言自行渲染
type LocationKind uint8
const (
LocationPrefix LocationKind = iota // 前缀匹配,含 ^~
LocationExact // = 精确匹配
LocationRegex // ~ 正则
LocationRegexAny // ~* 正则,不区分大小写
)
// ParseLocation 拆出匹配方式与其后的路径或正则。nginx 允许操作符与后串之间没有空格
func ParseLocation(location string) (LocationKind, string) {
location = strings.TrimSpace(location)
for _, op := range []struct {
prefix string
kind LocationKind
}{
{"~*", LocationRegexAny},
{"~", LocationRegex},
{"=", LocationExact},
{"^~", LocationPrefix},
} {
if rest, ok := strings.CutPrefix(location, op.prefix); ok {
return op.kind, strings.TrimSpace(rest)
}
}
return LocationPrefix, location
}
// NormalizePath 补上开头的斜杠
func NormalizePath(path string) string {
path = strings.TrimSpace(path)
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
+50
View File
@@ -0,0 +1,50 @@
package types
import "strings"
// VarMap nginx 变量与某个方言的占位符之间的双向映射。面板的头部值统一存 nginx 写法,
// 照搬到别的方言会被当字面量发给上游,所以写入时换成该方言的占位符、回读时换回来。
// 多个 nginx 变量映射到同一个占位符时,回读取表中首个,所以最常用的写法要排在前面
type VarMap struct {
toNative *strings.Replacer
toNginx *strings.Replacer
}
// NewVarMap 由 nginx 变量到方言占位符的有序表构造双向映射
func NewVarMap(pairs [][2]string) *VarMap {
native := make([]string, 0, len(pairs)*2)
nginx := make([]string, 0, len(pairs)*2)
seen := make(map[string]bool, len(pairs))
for _, p := range pairs {
native = append(native, p[0], p[1])
if !seen[p[1]] {
seen[p[1]] = true
nginx = append(nginx, p[1], p[0])
}
}
return &VarMap{toNative: strings.NewReplacer(native...), toNginx: strings.NewReplacer(nginx...)}
}
// ToNative 面板存的 nginx 写法转成方言占位符
func (m *VarMap) ToNative(value string) string {
return m.toNative.Replace(value)
}
// ToNginx 方言占位符转回 nginx 写法
func (m *VarMap) ToNginx(value string) string {
return m.toNginx.Replace(value)
}
// ApacheVars nginx 变量到 Apache 表达式的映射。表首命中即回读时的规范原像,所以 $host 要排在 $http_host 之前。
// OpenLiteSpeed 的 extraHeaders 用的也是 Apache 指令语法,两者共用
var ApacheVars = NewVarMap([][2]string{
{"$proxy_add_x_forwarded_for", "%{HTTP:X-Forwarded-For}"},
{"$remote_addr", "%{REMOTE_ADDR}"},
{"$host", "%{HTTP_HOST}"},
{"$http_host", "%{HTTP_HOST}"},
{"$request_uri", "%{REQUEST_URI}"},
{"$server_name", "%{SERVER_NAME}"},
{"$server_port", "%{SERVER_PORT}"},
{"$scheme", "%{REQUEST_SCHEME}"},
})
+4 -4
View File
@@ -16,7 +16,7 @@ export interface WebServerFeatures {
rateLimit: boolean // 限流限速
realIP: boolean // 真实 IP
lsCache: boolean // LiteSpeed 页面缓存
upstreamAlgos: string[] // 上游负载均衡算法,空字符串为默认轮询
upstreamAlgos: string[] // 上游负载均衡算法,取 nginx 写法,空字符串为默认轮询;方言表达不了的会被后端降级,不要在这里列方言原生名
lang: string // 配置文件语法高亮
}
@@ -35,7 +35,7 @@ const features: Record<string, WebServerFeatures> = {
rateLimit: true,
realIP: true,
lsCache: false,
upstreamAlgos: ['', 'least_conn', 'ip_hash', 'hash', 'random'],
upstreamAlgos: ['', 'least_conn', 'ip_hash', 'random'],
lang: 'nginx',
},
apache: {
@@ -52,7 +52,7 @@ const features: Record<string, WebServerFeatures> = {
rateLimit: true,
realIP: true,
lsCache: false,
upstreamAlgos: ['', 'bybusyness', 'bytraffic'],
upstreamAlgos: ['', 'least_conn'],
lang: 'apacheconf',
},
openlitespeed: {
@@ -86,7 +86,7 @@ const features: Record<string, WebServerFeatures> = {
rateLimit: false,
realIP: false,
lsCache: false,
upstreamAlgos: ['', 'least_conn', 'ip_hash', 'client_ip_hash', 'uri_hash', 'random', 'first'],
upstreamAlgos: ['', 'least_conn', 'ip_hash', 'random'],
lang: 'plaintext',
},
}
-2
View File
@@ -107,8 +107,6 @@ const features = computed(() => webserverFeatures(installedEnvironment.value.web
// 上游负载均衡算法选项,纯算法名不翻译
const upstreamAlgoLabels: Record<string, () => string> = {
'': () => $gettext('Round Robin (default)'),
bybusyness: () => $gettext('Least Busy'),
bytraffic: () => $gettext('By Traffic'),
}
const upstreamAlgoOptions = computed(() =>
features.value.upstreamAlgos.map((value) => ({