diff --git a/pkg/tamper/manager_linux.go b/pkg/tamper/manager_linux.go index dd080e9bb..6a93606b5 100644 --- a/pkg/tamper/manager_linux.go +++ b/pkg/tamper/manager_linux.go @@ -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 } diff --git a/pkg/webserver/apache/data.go b/pkg/webserver/apache/data.go index 9f9372e2f..a423e7122 100644 --- a/pkg/webserver/apache/data.go +++ b/pkg/webserver/apache/data.go @@ -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" diff --git a/pkg/webserver/apache/dialect.go b/pkg/webserver/apache/dialect.go index fcccae65a..727e484bd 100644 --- a/pkg/webserver/apache/dialect.go +++ b/pkg/webserver/apache/dialect.go @@ -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) } diff --git a/pkg/webserver/apache/parser_test.go b/pkg/webserver/apache/parser_test.go index 67ed47a46..bc84cae54 100644 --- a/pkg/webserver/apache/parser_test.go +++ b/pkg/webserver/apache/parser_test.go @@ -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 +} diff --git a/pkg/webserver/apache/proxy.go b/pkg/webserver/apache/proxy.go index f2d9cbc8a..f46fdca93 100644 --- a/pkg/webserver/apache/proxy.go +++ b/pkg/webserver/apache/proxy.go @@ -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 与 的路径前缀 } -// 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)) diff --git a/pkg/webserver/apache/vhost.go b/pkg/webserver/apache/vhost.go index 7ad7692e9..da8719c89 100644 --- a/pkg/webserver/apache/vhost.go +++ b/pkg/webserver/apache/vhost.go @@ -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 的监听写在 标签参数中,TLS 与否由块上的 SSLEngine 决定 + // Apache 的监听写在 标签参数中,是否走 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 { diff --git a/pkg/webserver/apache/vhost_test.go b/pkg/webserver/apache/vhost_test.go index c5dcb79f1..95fa90dee 100644 --- a/pkg/webserver/apache/vhost_test.go +++ b/pkg/webserver/apache/vhost_test.go @@ -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") } diff --git a/pkg/webserver/caddy/proxy.go b/pkg/webserver/caddy/proxy.go index f48e68670..915d869ef 100644 --- a/pkg/webserver/caddy/proxy.go +++ b/pkg/webserver/caddy/proxy.go @@ -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}"}, +}) diff --git a/pkg/webserver/caddy/vhost.go b/pkg/webserver/caddy/vhost.go index df3371f7e..00e0213d1 100644 --- a/pkg/webserver/caddy/vhost.go +++ b/pkg/webserver/caddy/vhost.go @@ -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 } diff --git a/pkg/webserver/conf/ast.go b/pkg/webserver/conf/ast.go index 0fc5f5d01..6f3f63c4f 100644 --- a/pkg/webserver/conf/ast.go +++ b/pkg/webserver/conf/ast.go @@ -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{} + } +} diff --git a/pkg/webserver/nginx/files.go b/pkg/webserver/nginx/files.go index 89de55058..cacc74d44 100644 --- a/pkg/webserver/nginx/files.go +++ b/pkg/webserver/nginx/files.go @@ -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 -} diff --git a/pkg/webserver/nginx/proxy.go b/pkg/webserver/nginx/proxy.go index 4ae17083d..f1be31ff1 100644 --- a/pkg/webserver/nginx/proxy.go +++ b/pkg/webserver/nginx/proxy.go @@ -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 { diff --git a/pkg/webserver/nginx/upstream.go b/pkg/webserver/nginx/upstream.go index be403ce7c..1fe222156 100644 --- a/pkg/webserver/nginx/upstream.go +++ b/pkg/webserver/nginx/upstream.go @@ -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 { diff --git a/pkg/webserver/openlitespeed/proxy.go b/pkg/webserver/openlitespeed/proxy.go index 6ee852e51..f0d522b7d 100644 --- a/pkg/webserver/openlitespeed/proxy.go +++ b/pkg/webserver/openlitespeed/proxy.go @@ -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 -} diff --git a/pkg/webserver/openlitespeed/vhost.go b/pkg/webserver/openlitespeed/vhost.go index 42026dd4a..847f1070b 100644 --- a/pkg/webserver/openlitespeed/vhost.go +++ b/pkg/webserver/openlitespeed/vhost.go @@ -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 } diff --git a/pkg/webserver/types/htpasswd.go b/pkg/webserver/types/htpasswd.go new file mode 100644 index 000000000..44b82ab2b --- /dev/null +++ b/pkg/webserver/types/htpasswd.go @@ -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 +} diff --git a/pkg/webserver/types/location.go b/pkg/webserver/types/location.go new file mode 100644 index 000000000..08e23a32e --- /dev/null +++ b/pkg/webserver/types/location.go @@ -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 +} diff --git a/pkg/webserver/types/vars.go b/pkg/webserver/types/vars.go new file mode 100644 index 000000000..482ea3bbe --- /dev/null +++ b/pkg/webserver/types/vars.go @@ -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}"}, +}) diff --git a/web/src/utils/webserver.ts b/web/src/utils/webserver.ts index f60b546c3..0cb8adf53 100644 --- a/web/src/utils/webserver.ts +++ b/web/src/utils/webserver.ts @@ -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 = { 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 = { rateLimit: true, realIP: true, lsCache: false, - upstreamAlgos: ['', 'bybusyness', 'bytraffic'], + upstreamAlgos: ['', 'least_conn'], lang: 'apacheconf', }, openlitespeed: { @@ -86,7 +86,7 @@ const features: Record = { 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', }, } diff --git a/web/src/views/website/EditModal.vue b/web/src/views/website/EditModal.vue index 9ed8a5d79..7dce8f7b5 100644 --- a/web/src/views/website/EditModal.vue +++ b/web/src/views/website/EditModal.vue @@ -107,8 +107,6 @@ const features = computed(() => webserverFeatures(installedEnvironment.value.web // 上游负载均衡算法选项,纯算法名不翻译 const upstreamAlgoLabels: Record string> = { '': () => $gettext('Round Robin (default)'), - bybusyness: () => $gettext('Least Busy'), - bytraffic: () => $gettext('By Traffic'), } const upstreamAlgoOptions = computed(() => features.value.upstreamAlgos.map((value) => ({