mirror of
https://github.com/tnb-labs/panel.git
synced 2026-10-16 07:21:35 +08:00
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:
+34
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}"},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user