diff --git a/agent/app/dto/nginx.go b/agent/app/dto/nginx.go index 9c61dc45e2d7..6c4076aa8d1c 100644 --- a/agent/app/dto/nginx.go +++ b/agent/app/dto/nginx.go @@ -51,13 +51,19 @@ const ( CACHE NginxKey = "cache" HttpPer NginxKey = "http-per" ProxyCache NginxKey = "proxy-cache" + Brotli NginxKey = "brotli" ) +// BrotliKeys are served from the panel-managed http.d file rather than +// nginx.conf, because the module is optional: its directives must disappear +// together with the module, otherwise nginx refuses to start. +var BrotliKeys = []string{"brotli", "brotli_comp_level", "brotli_min_length", "brotli_types"} + var ScopeKeyMap = map[NginxKey][]string{ Index: {"index"}, LimitConn: {"limit_conn", "limit_rate", "limit_conn_zone"}, SSL: {"ssl_certificate", "ssl_certificate_key"}, - HttpPer: {"server_names_hash_bucket_size", "client_header_buffer_size", "client_max_body_size", "keepalive_timeout", "gzip", "gzip_min_length", "gzip_comp_level"}, + HttpPer: {"server_names_hash_bucket_size", "client_header_buffer_size", "client_max_body_size", "keepalive_timeout", "gzip", "gzip_min_length", "gzip_comp_level", "gzip_types", "gzip_vary", "gzip_proxied"}, } var StaticFileKeyMap = map[NginxKey]struct { diff --git a/agent/app/dto/response/nginx.go b/agent/app/dto/response/nginx.go index 5136a9a1e76c..73468c339a46 100644 --- a/agent/app/dto/response/nginx.go +++ b/agent/app/dto/response/nginx.go @@ -17,6 +17,17 @@ type NginxParam struct { Params []string `json:"params"` } +// NginxBrotliRes carries the brotli settings together with where they live. +// ManagedExternally is true when the user defined brotli by hand, in which +// case the panel only reports the values and must not write its own copy. +// ManagedUnavailable is true when the panel could not wire the managed +// configuration into nginx.conf at all, so the reported values are inert. +type NginxBrotliRes struct { + Params []NginxParam `json:"params"` + ManagedExternally bool `json:"managedExternally"` + ManagedUnavailable bool `json:"managedUnavailable"` +} + type NginxAuthRes struct { Enable bool `json:"enable"` Items []dto.NginxAuth `json:"items"` diff --git a/agent/app/service/app_upgrade.go b/agent/app/service/app_upgrade.go index 2a768abc03e0..05bafc9444ac 100644 --- a/agent/app/service/app_upgrade.go +++ b/agent/app/service/app_upgrade.go @@ -459,6 +459,13 @@ func (u *appUpgradeContext) cutover(t *task.Task) error { }); err != nil { return err } + // Upgrades deliberately keep the user's nginx.conf, so corrected gzip + // defaults shipped with a new version would never reach existing + // installations. Rewrite only an untouched factory configuration, and + // never fail the upgrade over it. + if gzipErr := upgradeStockNginxGzipConfig(u.candidate); gzipErr != nil { + t.Logf("WARNING: update stock gzip configuration failed, keeping the current one: %v", gzipErr) + } } else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil { return err } diff --git a/agent/app/service/nginx.go b/agent/app/service/nginx.go index 063dea7af8da..32fa3d1b2b53 100644 --- a/agent/app/service/nginx.go +++ b/agent/app/service/nginx.go @@ -32,7 +32,7 @@ type NginxService struct { type INginxService interface { GetNginxConfig() (*response.NginxFile, error) - GetConfigByScope(req request.NginxScopeReq) ([]response.NginxParam, error) + GetConfigByScope(req request.NginxScopeReq) (interface{}, error) UpdateConfigByScope(req request.NginxConfigUpdate) error GetStatus() (response.NginxStatus, error) UpdateConfigFile(req request.NginxConfigFileUpdate) error @@ -62,7 +62,10 @@ func (n NginxService) GetNginxConfig() (*response.NginxFile, error) { return &response.NginxFile{Content: string(byteContent)}, nil } -func (n NginxService) GetConfigByScope(req request.NginxScopeReq) ([]response.NginxParam, error) { +func (n NginxService) GetConfigByScope(req request.NginxScopeReq) (interface{}, error) { + if req.Scope == dto.Brotli { + return getNginxBrotliParams() + } keys, ok := dto.ScopeKeyMap[req.Scope] if !ok || len(keys) == 0 { return nil, nil @@ -71,6 +74,9 @@ func (n NginxService) GetConfigByScope(req request.NginxScopeReq) ([]response.Ng } func (n NginxService) UpdateConfigByScope(req request.NginxConfigUpdate) error { + if req.Scope == dto.Brotli { + return updateNginxBrotliParams(getNginxParams(req.Params, dto.BrotliKeys)) + } keys, ok := dto.ScopeKeyMap[req.Scope] if !ok || len(keys) == 0 { return nil diff --git a/agent/app/service/nginx_gzip_upgrade.go b/agent/app/service/nginx_gzip_upgrade.go new file mode 100644 index 000000000000..cc8b26349f83 --- /dev/null +++ b/agent/app/service/nginx_gzip_upgrade.go @@ -0,0 +1,168 @@ +package service + +import ( + "os" + "path" + "regexp" + "sort" + "strings" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/global" +) + +// stockNginxGzipDirectives is the gzip block shipped by the OpenResty app +// since 1.21.4.3. The upgrade only rewrites values when the installed +// nginx.conf still carries exactly these directives and values, which proves +// the user never tuned compression. Any deviation aborts the rewrite. +var stockNginxGzipDirectives = map[string]string{ + "gzip": "on", + "gzip_min_length": "1k", + "gzip_buffers": "4 16k", + "gzip_http_version": "1.1", + "gzip_comp_level": "2", + "gzip_types": "text/plain application/javascript application/x-javascript text/javascript text/css application/xml", + "gzip_vary": "on", + "gzip_proxied": "expired no-cache no-store private auth", + "gzip_disable": `"MSIE [1-6]\."`, +} + +// correctedNginxGzipDirectives replaces the stock values in place. gzip lives +// in the http block of nginx.conf and must stay there: repeating it from an +// included file would make nginx reject the configuration with a duplicate +// directive error, and the compression settings page reads and writes these +// same keys in nginx.conf. +var correctedNginxGzipDirectives = map[string]string{ + "gzip_comp_level": "5", + "gzip_types": strings.Join(nginxCompressibleTypes, " "), + "gzip_proxied": "any", +} + +// obsoleteNginxGzipDirectives are dropped outright. +var obsoleteNginxGzipDirectives = map[string]struct{}{ + // A per-request User-Agent regex for browsers with no measurable share. + "gzip_disable": {}, +} + +var nginxGzipDirectiveRe = regexp.MustCompile(`(?m)^[ \t]*(gzip[a-z_]*)[ \t]+([^;\n]*);[ \t]*$`) + +func nginxMainConfigPath(install model.AppInstall) string { + return path.Join(install.GetPath(), nginxModuleConfDir, "nginx.conf") +} + +// upgradeStockNginxGzipConfig rewrites the factory gzip defaults in place. +// +// Upgrades deliberately preserve the user's nginx.conf, so corrected defaults +// shipped with a new OpenResty version would otherwise never reach existing +// installations. +// +// The config parser is not used: its dumper regenerates the whole file, drops +// standalone comments and reorders proxy includes, which would be destructive +// on a user's main config. Lines are edited individually so everything outside +// the gzip block stays byte-identical. +func upgradeStockNginxGzipConfig(install model.AppInstall) error { + configPath := nginxMainConfigPath(install) + content, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !isStockNginxGzipConfig(string(content)) { + return nil + } + updated := rewriteNginxGzipDirectives(string(content)) + if updated == string(content) { + return nil + } + if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil { + return err + } + if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil { + return err + } + global.LOG.Info("updated the stock OpenResty gzip configuration to the current defaults") + return nil +} + +// isStockNginxGzipConfig reports whether every gzip directive in the config +// matches the factory defaults exactly, with none missing and none extra. +func isStockNginxGzipConfig(content string) bool { + found := make(map[string]string) + for _, match := range nginxGzipDirectiveRe.FindAllStringSubmatch(content, -1) { + name := match[1] + value := strings.Join(strings.Fields(match[2]), " ") + if _, ok := found[name]; ok { + // A directive repeated in the http block means the config was + // edited by hand; leave it alone. + return false + } + found[name] = value + } + if len(found) != len(stockNginxGzipDirectives) { + return false + } + for name, expected := range stockNginxGzipDirectives { + if found[name] != expected { + return false + } + } + return true +} + +// rewriteNginxGzipDirectives updates known values in place, drops obsolete +// directives and appends directives that are missing, preserving the original +// indentation and leaving every other line untouched. +func rewriteNginxGzipDirectives(content string) string { + lines := strings.Split(content, "\n") + result := make([]string, 0, len(lines)) + seen := make(map[string]struct{}) + lastGzipIndex := -1 + lastGzipIndent := " " + + for _, line := range lines { + match := nginxGzipDirectiveRe.FindStringSubmatch(line) + if match == nil { + result = append(result, line) + continue + } + name := match[1] + // The indentation belongs to the line itself; a top-level directive + // must not inherit the indent a previous, nested directive used. + lineIndent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + if lineIndent == "" { + lineIndent = " " + } + lastGzipIndent = lineIndent + if _, obsolete := obsoleteNginxGzipDirectives[name]; obsolete { + continue + } + seen[name] = struct{}{} + if replacement, ok := correctedNginxGzipDirectives[name]; ok { + result = append(result, lineIndent+name+" "+replacement+";") + } else { + result = append(result, line) + } + lastGzipIndex = len(result) - 1 + } + + // Directives introduced by a newer default set are appended right after + // the existing block so they stay visually grouped. + var missing []string + for name := range correctedNginxGzipDirectives { + if _, ok := seen[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) == 0 || lastGzipIndex < 0 { + return strings.Join(result, "\n") + } + sort.Strings(missing) + added := make([]string, 0, len(missing)) + for _, name := range missing { + added = append(added, lastGzipIndent+name+" "+correctedNginxGzipDirectives[name]+";") + } + tail := append(added, result[lastGzipIndex+1:]...) + return strings.Join(append(result[:lastGzipIndex+1], tail...), "\n") +} diff --git a/agent/app/service/nginx_gzip_upgrade_test.go b/agent/app/service/nginx_gzip_upgrade_test.go new file mode 100644 index 000000000000..9ebe148296d6 --- /dev/null +++ b/agent/app/service/nginx_gzip_upgrade_test.go @@ -0,0 +1,182 @@ +package service + +import ( + "strings" + "testing" + + "github.com/1Panel-dev/1Panel/agent/cmd/server/nginx_conf" +) + +const stockNginxConf = `user root; +worker_processes auto; + +include /usr/local/openresty/nginx/conf/modules-enabled/*.conf; + +events { + use epoll; +} + +http { + include mime.types; + default_type application/octet-stream; + + server_names_hash_bucket_size 512; + keepalive_requests 5000; + + gzip on; + gzip_min_length 1k; + gzip_buffers 4 16k; + gzip_http_version 1.1; + gzip_comp_level 2; + gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml; + gzip_vary on; + gzip_proxied expired no-cache no-store private auth; + gzip_disable "MSIE [1-6]\."; + + limit_conn_zone $binary_remote_addr zone=perip:10m; + + include /usr/local/openresty/nginx/conf/http.d/*.conf; + include /usr/local/openresty/nginx/conf/conf.d/*.conf; +} +` + +func TestIsStockNginxGzipConfig(t *testing.T) { + if !isStockNginxGzipConfig(stockNginxConf) { + t.Fatal("factory configuration should be detected as stock") + } +} + +func TestIsStockNginxGzipConfigRejectsTunedValues(t *testing.T) { + cases := map[string]string{ + "comp level changed": strings.Replace(stockNginxConf, "gzip_comp_level 2;", "gzip_comp_level 6;", 1), + "gzip disabled": strings.Replace(stockNginxConf, "gzip on;", "gzip off;", 1), + "types extended": strings.Replace(stockNginxConf, + "application/xml;", "application/xml application/json;", 1), + "directive removed": strings.Replace(stockNginxConf, " gzip_vary on;\n", "", 1), + "directive added": strings.Replace(stockNginxConf, " gzip_vary on;\n", + " gzip_vary on;\n gzip_static on;\n", 1), + } + for name, content := range cases { + if isStockNginxGzipConfig(content) { + t.Errorf("%s: tuned configuration must not be rewritten", name) + } + } +} + +func TestIsStockNginxGzipConfigRejectsDuplicateDirective(t *testing.T) { + content := strings.Replace(stockNginxConf, " gzip on;\n", " gzip on;\n gzip on;\n", 1) + if isStockNginxGzipConfig(content) { + t.Fatal("a duplicated directive indicates a hand-edited config") + } +} + +func TestRewriteNginxGzipDirectives(t *testing.T) { + result := rewriteNginxGzipDirectives(stockNginxConf) + + for _, expected := range []string{ + " gzip_comp_level 5;", + " gzip_proxied any;", + " gzip on;", + " gzip_vary on;", + } { + if !strings.Contains(result, expected) { + t.Errorf("expected directive missing: %s\n%s", expected, result) + } + } + if !strings.Contains(result, "application/json") { + t.Error("gzip_types should now cover application/json") + } + if strings.Contains(result, "gzip_disable") { + t.Error("obsolete gzip_disable should have been dropped") + } + if strings.Contains(result, "gzip_comp_level 2;") { + t.Error("stale comp level should have been replaced") + } + // Everything outside the gzip block must survive untouched. + for _, keep := range []string{ + "server_names_hash_bucket_size 512;", + "keepalive_requests 5000;", + "limit_conn_zone $binary_remote_addr zone=perip:10m;", + "include /usr/local/openresty/nginx/conf/http.d/*.conf;", + "include /usr/local/openresty/nginx/conf/conf.d/*.conf;", + "include /usr/local/openresty/nginx/conf/modules-enabled/*.conf;", + "user root;", + } { + if !strings.Contains(result, keep) { + t.Errorf("unrelated line was altered or dropped: %s", keep) + } + } + if !strings.HasSuffix(result, "}\n") { + t.Error("trailing newline was not preserved") + } +} + +func TestRewriteNginxGzipDirectivesIsIdempotent(t *testing.T) { + once := rewriteNginxGzipDirectives(stockNginxConf) + twice := rewriteNginxGzipDirectives(once) + if once != twice { + t.Errorf("rewrite is not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", once, twice) + } +} + +func TestRewriteNginxGzipDirectivesAppendsMissing(t *testing.T) { + // gzip_proxied absent from the source must be appended, not silently lost. + content := strings.Replace(stockNginxConf, + " gzip_proxied expired no-cache no-store private auth;\n", "", 1) + result := rewriteNginxGzipDirectives(content) + if !strings.Contains(result, "gzip_proxied any;") { + t.Errorf("missing directive was not appended:\n%s", result) + } + if !strings.Contains(result, "limit_conn_zone $binary_remote_addr zone=perip:10m;") { + t.Error("appending must not clobber following lines") + } +} + +func TestRewriteNginxGzipDirectivesKeepsGzipLikeNames(t *testing.T) { + // gunzip and proxy_set_header must survive: only directives whose name + // starts with "gzip" are managed here. + content := "http {\n gunzip on;\n gzip on;\n proxy_set_header Accept-Encoding gzip;\n}\n" + result := rewriteNginxGzipDirectives(content) + if !strings.Contains(result, "gunzip on;") { + t.Error("gunzip directive must be preserved") + } + if !strings.Contains(result, "proxy_set_header Accept-Encoding gzip;") { + t.Error("proxy_set_header must be preserved") + } + if !strings.Contains(result, " gzip on;") { + t.Error("gzip directive should be kept in place") + } +} + +// The gzip.conf template, the upgrade maps and the appstore defaults are three +// copies of one intent. Pin the first two so they cannot drift apart silently. +func TestGzipTemplateMatchesCorrectedDefaults(t *testing.T) { + template := nginx_conf.GetWebsiteFile("gzip.conf") + if len(template) == 0 { + t.Fatal("gzip.conf template is missing from the embedded files") + } + expected := make(map[string]string, len(stockNginxGzipDirectives)) + for name, value := range stockNginxGzipDirectives { + expected[name] = value + } + for name := range obsoleteNginxGzipDirectives { + delete(expected, name) + } + for name, value := range correctedNginxGzipDirectives { + expected[name] = value + } + found := make(map[string]string) + for _, match := range nginxGzipDirectiveRe.FindAllStringSubmatch(string(template), -1) { + found[match[1]] = strings.Join(strings.Fields(match[2]), " ") + } + if len(found) != len(expected) { + t.Fatalf("template has %d directives, corrected defaults have %d", len(found), len(expected)) + } + for name, want := range expected { + if got, ok := found[name]; !ok { + t.Errorf("template is missing %s", name) + } else if got != want { + t.Errorf("%s: template has %q, corrected defaults have %q", name, got, want) + } + } +} diff --git a/agent/app/service/nginx_http_config.go b/agent/app/service/nginx_http_config.go new file mode 100644 index 000000000000..f6474f9f7fc0 --- /dev/null +++ b/agent/app/service/nginx_http_config.go @@ -0,0 +1,245 @@ +package service + +import ( + "errors" + "fmt" + "os" + "path" + "regexp" + "sort" + "strings" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" +) + +const ( + // nginxHTTPConfDir holds http-context directives generated by 1Panel. + // load_module is a main-context directive and therefore lives in + // modules-enabled, which cannot host http-context directives such as + // "brotli on". The directory is included by nginx.conf before conf.d so + // that per-site configuration keeps overriding these defaults. + nginxHTTPConfDir = "http.d" + + nginxHTTPConfigPrefix = "1panel-http-" + nginxHTTPConfigHeader = "# Managed by 1Panel. Manual changes will be overwritten.\n" + + // nginxHTTPIncludeDirective is the include line that loads the managed + // directory. Fresh installs carry it in the shipped nginx.conf; existing + // ones get it inserted by the panel the first time a module needs + // http-context configuration. + nginxHTTPIncludeDirective = "include /usr/local/openresty/nginx/conf/http.d/*.conf;" +) + +var ( + // nginxHTTPIncludeRe matches the include line wherever it appears. The + // absolute path prefix, quoting and whitespace are all optional in the + // match so a variant written by an older installer or by hand still + // counts; a commented-out copy does not. + nginxHTTPIncludeRe = regexp.MustCompile(`(?m)^[ \t]*include\s+"?(/usr/local/openresty/nginx/conf/)?http\.d/\*\.conf"?\s*;[ \t]*\r?$`) + + // nginxConfDIncludeRe locates the site-config include, the preferred + // insertion point, and captures its indentation. + nginxConfDIncludeRe = regexp.MustCompile(`(?m)^([ \t]*)include\s+"?(/usr/local/openresty/nginx/conf/)?conf\.d/\*\.conf"?\s*;[ \t]*\r?$`) + + // nginxHTTPBlockStartRe locates the http block opening, the fallback + // insertion point, and captures its indentation. + nginxHTTPBlockStartRe = regexp.MustCompile(`(?m)^([ \t]*)http[ \t]*\{[ \t]*\r?$`) +) + +// nginxHTTPIncludePresent reports whether nginx.conf already loads http.d. +func nginxHTTPIncludePresent(install model.AppInstall) bool { + content, err := os.ReadFile(nginxMainConfigPath(install)) + if err != nil { + return false + } + return nginxHTTPIncludeRe.MatchString(string(content)) +} + +// writeNginxFileAtomic writes through a temp file plus rename so a crash or a +// concurrent reader never observes a half-written config. +func writeNginxFileAtomic(filePath string, content []byte) error { + tmpPath := filePath + ".tmp" + if err := os.WriteFile(tmpPath, content, constant.FilePerm); err != nil { + return err + } + return os.Rename(tmpPath, filePath) +} + +// nginxFileLineEnding picks the file's own style so an inserted or rewritten +// line does not mix LF into a CRLF file. +func nginxFileLineEnding(content string) string { + if strings.Contains(content, "\r\n") { + return "\r\n" + } + return "\n" +} + +// insertNginxHTTPInclude returns the config with the http.d include added. +// +// The include goes right before the conf.d include so panel-managed defaults +// are evaluated before per-site configuration; without one, it goes at the +// top of the http block. The inserted line follows the file's own line-ending +// style, and everything else stays byte-identical. A config without a +// locatable http block is rejected, and callers degrade instead of failing +// their operation over it. +func insertNginxHTTPInclude(content string) (string, error) { + if nginxHTTPIncludeRe.MatchString(content) { + return content, nil + } + eol := nginxFileLineEnding(content) + if m := nginxConfDIncludeRe.FindStringSubmatchIndex(content); m != nil { + indent := content[m[2]:m[3]] + return content[:m[0]] + indent + nginxHTTPIncludeDirective + eol + content[m[0]:], nil + } + if m := nginxHTTPBlockStartRe.FindStringSubmatchIndex(content); m != nil { + indent := content[m[2]:m[3]] + " " + return content[:m[1]] + eol + indent + nginxHTTPIncludeDirective + content[m[1]:], nil + } + return "", errors.New("no insertion point for the http.d include in nginx.conf") +} + +// ensureNginxHTTPIncludeActive makes nginx.conf load http.d, inserting the +// include when missing. It returns whether the directory is loaded after the +// call, plus the original config content so the caller can roll back the edit +// together with the rest of its changes. +func ensureNginxHTTPIncludeActive(install model.AppInstall) (active bool, snapshot []byte, err error) { + configPath := nginxMainConfigPath(install) + content, readErr := os.ReadFile(configPath) + if readErr != nil { + return false, nil, readErr + } + if nginxHTTPIncludeRe.MatchString(string(content)) { + if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil { + return false, nil, err + } + return true, nil, nil + } + updated, insErr := insertNginxHTTPInclude(string(content)) + if insErr != nil { + return false, nil, insErr + } + if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil { + return false, nil, err + } + if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil { + return false, nil, err + } + return true, content, nil +} + +// nginxHTTPDirective is a single http-context directive rendered into a +// managed file. +type nginxHTTPDirective struct { + Name string + Params []string +} + +func (d nginxHTTPDirective) render() string { + if len(d.Params) == 0 { + return d.Name + ";" + } + return d.Name + " " + strings.Join(d.Params, " ") + ";" +} + +func nginxHTTPConfigDir(install model.AppInstall) string { + return path.Join(install.GetPath(), nginxModuleConfDir, nginxHTTPConfDir) +} + +func nginxHTTPConfigFileName(order int, name string) string { + return fmt.Sprintf("%s%04d-%s.conf", nginxHTTPConfigPrefix, order, nginxModulePathName(name)) +} + +// renderNginxHTTPConfig builds the content of a managed http.d file. +func renderNginxHTTPConfig(directives []nginxHTTPDirective) []byte { + var content strings.Builder + content.WriteString(nginxHTTPConfigHeader) + for _, directive := range directives { + content.WriteString(directive.render()) + content.WriteString("\n") + } + return []byte(content.String()) +} + +var nginxHTTPDirectiveRe = regexp.MustCompile(`^[ \t]*([a-z_][a-z0-9_]*)[ \t]+([^;]*);[ \t]*$`) + +// readNginxHTTPDirectives parses a managed file back into directive values. +// A missing or unreadable file yields no directives, which makes callers fall +// back to their defaults. +func readNginxHTTPDirectives(filePath string) map[string][]string { + content, err := os.ReadFile(filePath) + if err != nil { + return nil + } + directives := make(map[string][]string) + for _, line := range strings.Split(string(content), "\n") { + match := nginxHTTPDirectiveRe.FindStringSubmatch(line) + if match == nil { + continue + } + directives[match[1]] = strings.Fields(match[2]) + } + return directives +} + +// snapshotManagedNginxHTTPConfigs captures every managed file so a failed +// nginx -t can be rolled back. +func snapshotManagedNginxHTTPConfigs(configDir string) (nginxModuleConfigSnapshot, error) { + snapshot := make(nginxModuleConfigSnapshot) + entries, err := os.ReadDir(configDir) + if err != nil { + if os.IsNotExist(err) { + return snapshot, nil + } + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxHTTPConfigPrefix) { + continue + } + content, readErr := os.ReadFile(path.Join(configDir, entry.Name())) + if readErr != nil { + return nil, readErr + } + snapshot[entry.Name()] = content + } + return snapshot, nil +} + +// applyManagedNginxHTTPConfigs writes the desired managed files and removes +// managed files that are no longer wanted. Files not carrying the managed +// prefix are never touched. +func applyManagedNginxHTTPConfigs(configDir string, desired map[string][]byte) error { + if err := os.MkdirAll(configDir, constant.DirPerm); err != nil { + return err + } + entries, err := os.ReadDir(configDir) + if err != nil { + return err + } + names := make([]string, 0, len(desired)) + for fileName := range desired { + names = append(names, fileName) + } + sort.Strings(names) + for _, fileName := range names { + tmpPath := path.Join(configDir, "."+fileName+".tmp") + if err = os.WriteFile(tmpPath, desired[fileName], constant.FilePerm); err != nil { + return err + } + if err = os.Rename(tmpPath, path.Join(configDir, fileName)); err != nil { + return err + } + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxHTTPConfigPrefix) { + continue + } + if _, ok := desired[entry.Name()]; !ok { + if err = os.Remove(path.Join(configDir, entry.Name())); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} diff --git a/agent/app/service/nginx_http_config_test.go b/agent/app/service/nginx_http_config_test.go new file mode 100644 index 000000000000..1071b4dfcc9b --- /dev/null +++ b/agent/app/service/nginx_http_config_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "strings" + "testing" +) + +const plainNginxConf = `user root; +worker_processes auto; + +include /usr/local/openresty/nginx/conf/modules-enabled/*.conf; + +events { + use epoll; +} + +http { + include mime.types; + default_type application/octet-stream; + + gzip on; + gzip_comp_level 5; + + limit_conn_zone $binary_remote_addr zone=perip:10m; + + include /usr/local/openresty/nginx/conf/conf.d/*.conf; + include /usr/local/openresty/nginx/conf/default/*.conf; +} +` + +func TestInsertNginxHTTPIncludeBeforeConfD(t *testing.T) { + got, err := insertNginxHTTPInclude(plainNginxConf) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, " "+nginxHTTPIncludeDirective) { + t.Fatalf("include not inserted with matching indent:\n%s", got) + } + // Ordering is the point of the insertion site: panel defaults must be + // evaluated before per-site configuration. + httpIdx := strings.Index(got, "conf/http.d/*.conf") + confDIdx := strings.Index(got, "conf/conf.d/*.conf") + if httpIdx < 0 || confDIdx < 0 || httpIdx > confDIdx { + t.Fatalf("http.d must be included before conf.d (http.d=%d conf.d=%d)", httpIdx, confDIdx) + } + // The rest of the file must be untouched. + stripped := strings.Replace(got, " "+nginxHTTPIncludeDirective+"\n", "", 1) + if stripped != plainNginxConf { + t.Fatal("insertion altered content outside the inserted line") + } +} + +func TestInsertNginxHTTPIncludeIsIdempotent(t *testing.T) { + once, err := insertNginxHTTPInclude(plainNginxConf) + if err != nil { + t.Fatal(err) + } + twice, err := insertNginxHTTPInclude(once) + if err != nil { + t.Fatal(err) + } + if twice != once { + t.Fatal("a second insertion must be a no-op") + } +} + +func TestInsertNginxHTTPIncludeFallsBackToHTTPBlock(t *testing.T) { + content := strings.Replace(plainNginxConf, + " include /usr/local/openresty/nginx/conf/conf.d/*.conf;\n", "", 1) + got, err := insertNginxHTTPInclude(content) + if err != nil { + t.Fatal(err) + } + httpIdx := strings.Index(got, "http {") + incIdx := strings.Index(got, nginxHTTPIncludeDirective) + if incIdx < 0 || incIdx < httpIdx { + t.Fatalf("include should land inside the http block:\n%s", got) + } + // Indented one level deeper than the http keyword. + if !strings.Contains(got, " "+nginxHTTPIncludeDirective) { + t.Errorf("fallback indentation is wrong:\n%s", got) + } +} + +func TestInsertNginxHTTPIncludeRejectsConfigWithoutHTTPBlock(t *testing.T) { + if _, err := insertNginxHTTPInclude("events {}\n"); err == nil { + t.Fatal("a config without an http block must be rejected so callers can degrade") + } +} + +func TestInsertNginxHTTPIncludeIgnoresCommentedIncludes(t *testing.T) { + commented := strings.Replace(plainNginxConf, + " include /usr/local/openresty/nginx/conf/conf.d/*.conf;", + " # include /usr/local/openresty/nginx/conf/conf.d/*.conf;", 1) + got, err := insertNginxHTTPInclude(commented) + if err != nil { + t.Fatal(err) + } + // The commented conf.d line is not a valid anchor; the fallback must win. + if strings.Index(got, nginxHTTPIncludeDirective) < strings.Index(got, "http {") { + t.Fatal("a commented include must not be used as the anchor") + } +} + +func TestInsertNginxHTTPIncludeHandlesCRLF(t *testing.T) { + crlf := strings.ReplaceAll(plainNginxConf, "\n", "\r\n") + got, err := insertNginxHTTPInclude(crlf) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, nginxHTTPIncludeDirective) { + t.Fatal("include missing on a CRLF file") + } +} + +func TestNginxHTTPIncludeRe(t *testing.T) { + cases := []struct { + name string + content string + want bool + }{ + {"present", plainNginxConf + " " + nginxHTTPIncludeDirective + "\n", true}, + {"absent", plainNginxConf, false}, + {"commented out", "# " + nginxHTTPIncludeDirective, false}, + // nginx accepts quoted paths, and a hand-written or legacy installer + // may use them; a quoted include must count as present. + {"quoted absolute path", `include "/usr/local/openresty/nginx/conf/http.d/*.conf";`, true}, + {"quoted with extra whitespace", ` include "/usr/local/openresty/nginx/conf/http.d/*.conf" ;`, true}, + {"relative path form", ` include http.d/*.conf;`, true}, + {"a different directory does not count", ` include /usr/local/openresty/nginx/conf/conf.d/*.conf;`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nginxHTTPIncludeRe.MatchString(tc.content); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +// The anchor for insertion must tolerate the same variants, or a config with +// a quoted conf.d include would take the http-block fallback for no reason. +func TestInsertNginxHTTPIncludeWithQuotedConfDAnchor(t *testing.T) { + quoted := strings.Replace(plainNginxConf, + " include /usr/local/openresty/nginx/conf/conf.d/*.conf;", + ` include "/usr/local/openresty/nginx/conf/conf.d/*.conf";`, 1) + got, err := insertNginxHTTPInclude(quoted) + if err != nil { + t.Fatal(err) + } + httpIdx := strings.Index(got, "conf/http.d/*.conf") + confDIdx := strings.Index(got, "conf/conf.d/*.conf") + if httpIdx < 0 || confDIdx < 0 || httpIdx > confDIdx { + t.Fatalf("quoted anchor not used; include misplaced:\n%s", got) + } +} + +// A CRLF file must keep its own line endings after insertion. +func TestInsertNginxHTTPIncludeKeepsCRLFStyle(t *testing.T) { + crlf := strings.ReplaceAll(plainNginxConf, "\n", "\r\n") + got, err := insertNginxHTTPInclude(crlf) + if err != nil { + t.Fatal(err) + } + idx := strings.Index(got, nginxHTTPIncludeDirective) + if idx < 0 { + t.Fatal("include missing") + } + if got[idx-1] == '\n' || (idx >= 2 && got[idx-2:idx] != "\r\n" && got[idx-1] != ' ') { + // The inserted line must end with \r\n like the rest of the file. + } + end := idx + len(nginxHTTPIncludeDirective) + if end+2 > len(got) || got[end:end+2] != "\r\n" { + t.Fatalf("inserted line does not end with CRLF: %q", got[end:end+4]) + } +} diff --git a/agent/app/service/nginx_module.go b/agent/app/service/nginx_module.go index d299cb6b0a4b..f29af7b3eb0e 100644 --- a/agent/app/service/nginx_module.go +++ b/agent/app/service/nginx_module.go @@ -18,6 +18,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/cmd" @@ -164,6 +165,51 @@ func nginxModuleDynamicSupported(install model.AppInstall) bool { fileOp.Stat(path.Join(buildPath, nginxModuleCatalogFile)) } +// nginxModuleStaticSupported reports whether the install can recompile its own +// OpenResty image, which is what a static module build needs. Versions before +// dynamic modules existed ship a compose file with a build section and the +// sources under build/; the oldest ones only reference a prebuilt image and +// cannot compile anything. +func nginxModuleStaticSupported(install model.AppInstall) bool { + if !files.NewFileOp().Stat(path.Join(install.GetPath(), nginxModuleBuildDir, "Dockerfile")) { + return false + } + envStr, err := coverEnvJsonToStr(install.Env) + if err != nil { + return false + } + project, err := dockerUtils.GetComposeProject(install.Name, install.GetPath(), + []byte(install.DockerCompose), []byte(envStr), true) + if err != nil { + return false + } + for _, service := range project.AllServices() { + if service.Build != nil { + return true + } + } + return false +} + +// defaultNginxModuleBuildMode picks the mode an install can actually perform. +// +// Module state written before build modes existed carries no buildMode at all. +// Rejecting it would fail loadNginxModules, and with it every module operation +// and the upgrade itself, so the value is inferred from what the install can +// do rather than assumed. +func defaultNginxModuleBuildMode(install model.AppInstall) string { + if nginxModuleDynamicSupported(install) { + return nginxModuleBuildDynamic + } + if nginxModuleStaticSupported(install) { + return nginxModuleBuildStatic + } + // Neither builder is available. Dynamic keeps the module inert instead of + // triggering an image rebuild that cannot succeed; the build itself still + // reports the missing capability. + return nginxModuleBuildDynamic +} + func syncNginxModuleBuilder(detailBuildDir, installBuildDir string) error { sourcePath := path.Join(detailBuildDir, nginxModuleBuilderFile) targetPath := path.Join(installBuildDir, nginxModuleBuilderFile) @@ -654,10 +700,56 @@ func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.N return fmt.Errorf("validate combined dynamic module configuration: %w", err) } } - if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil { + + // Runtime directives live in http.d because load_module is main-context + // while directives such as "brotli on" are http-context. Both sets are + // written before nginx -t runs, so nginx only ever observes the final, + // consistent state; on failure both are rolled back together. + // + // The include that loads http.d is inserted on demand: only when a module + // actually needs runtime configuration. An install whose nginx.conf cannot + // be edited safely keeps the previous behaviour — the module loads but the + // runtime directives are skipped — rather than failing the operation. + httpConfigDir := nginxHTTPConfigDir(install) + desiredHTTP := desiredNginxModuleRuntimeConfigs(install, modules, target) + httpActive := nginxHTTPIncludePresent(install) + var nginxConfSnapshot []byte + if len(desiredHTTP) > 0 && !httpActive { + active, confSnapshot, includeErr := ensureNginxHTTPIncludeActive(install) + if includeErr != nil { + global.LOG.Warnf("cannot insert the http.d include into nginx.conf, skipping runtime directives: %v", includeErr) + desiredHTTP = nil + } else { + httpActive = active + nginxConfSnapshot = confSnapshot + } + } + var httpSnapshot nginxModuleConfigSnapshot + if httpActive { + if httpSnapshot, err = snapshotManagedNginxHTTPConfigs(httpConfigDir); err != nil { + return err + } + } + restore := func() { _ = applyManagedNginxModuleConfigs(configDir, snapshot) + if httpActive { + _ = applyManagedNginxHTTPConfigs(httpConfigDir, httpSnapshot) + } + if nginxConfSnapshot != nil { + _ = os.WriteFile(nginxMainConfigPath(install), nginxConfSnapshot, constant.FilePerm) + } + } + + if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil { + restore() return err } + if httpActive { + if err = applyManagedNginxHTTPConfigs(httpConfigDir, desiredHTTP); err != nil { + restore() + return err + } + } if !reload { return nil } @@ -666,11 +758,11 @@ func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.N return nil } if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { - _ = applyManagedNginxModuleConfigs(configDir, snapshot) + restore() return err } if err = opNginx(install.ContainerName, constant.NginxReload); err != nil { - _ = applyManagedNginxModuleConfigs(configDir, snapshot) + restore() return err } return nil @@ -722,6 +814,14 @@ func applyManagedNginxModuleConfigs(configDir string, desired map[string][]byte) return nil } +// hasEnabledStaticNginxModules reports whether a full image rebuild is needed. +// +// Module state is the only input on purpose. RESTY_CONFIG_OPTIONS_MORE in .env +// is derived state: configureStaticNginxModules rewrites it from the modules +// below, and every build path calls that function before building. Treating a +// leftover value as a reason to rebuild would start a full recompile that +// configureStaticNginxModules has already reduced to an empty option list, so +// the rebuild could only reproduce the image it started from. func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool { for _, module := range modules { normalizeNginxModule(&module) @@ -732,17 +832,6 @@ func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool { return false } -func staticNginxBuildRequired(install model.AppInstall, modules []dto.NginxModule) bool { - if hasEnabledStaticNginxModules(modules) { - return true - } - envs, err := gotenv.Read(install.GetEnvPath()) - if err != nil { - return false - } - return strings.TrimSpace(envs["RESTY_CONFIG_OPTIONS_MORE"]) != "" -} - func configureStaticNginxModules(install model.AppInstall, modules []dto.NginxModule, mirror string) error { buildPath := path.Join(install.GetPath(), nginxModuleBuildDir) var params, packages []string @@ -807,11 +896,17 @@ func executeNginxModuleBuild(install model.AppInstall, reqModules []string, forc if err != nil { return err } - staticBuild := staticNginxBuildRequired(install, modules) - if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) { - if !nginxModuleDynamicSupported(install) { - return errors.New("the installed OpenResty version does not support dynamic module builds") - } + // Only the module list decides this. A leftover RESTY_CONFIG_OPTIONS_MORE + // used to force the static path here, which meant a full image rebuild for + // an install that has no static module left to compile. + staticBuild := hasEnabledStaticNginxModules(modules) + if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) && !nginxModuleDynamicSupported(install) { + // The catalog and the builder have always shipped together, and an + // install missing the catalog fails to load its module state before + // this point, so this branch is a guard rather than a real path. Keep + // the error actionable instead of faking a build the state machine + // cannot record. + return buserr.New("ErrModuleBuildUnsupported") } if staticBuild { return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask) @@ -886,7 +981,17 @@ func loadNginxModulesWithCatalog(install model.AppInstall, catalogPath string) ( Builds: state.Builds, LastError: state.LastError, }) } + // Catalog entries always declare a mode; state written before build modes + // existed does not. Fill the gap from the install's capabilities so an + // upgrade from such a version can still read its own module state. + fallbackMode := "" for i := range modules { + if modules[i].BuildMode == "" { + if fallbackMode == "" { + fallbackMode = defaultNginxModuleBuildMode(install) + } + modules[i].BuildMode = fallbackMode + } if err = validateNginxModuleBuildMode(modules[i]); err != nil { return nil, err } diff --git a/agent/app/service/nginx_module_runtime.go b/agent/app/service/nginx_module_runtime.go new file mode 100644 index 000000000000..ad4ff6957cfe --- /dev/null +++ b/agent/app/service/nginx_module_runtime.go @@ -0,0 +1,483 @@ +package service + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/dto/response" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/utils/cmd" +) + +// nginxCompressibleTypes is shared by gzip_types and brotli_types so both +// encoders cover the same content. Already compressed formats (images other +// than SVG, woff/woff2, archives, media) are deliberately excluded: +// recompressing them costs CPU and usually grows the payload. +var nginxCompressibleTypes = []string{ + "text/plain", + "text/css", + "text/xml", + "text/javascript", + "application/json", + "application/ld+json", + "application/javascript", + "application/x-javascript", + "application/xml", + "application/xhtml+xml", + "application/rss+xml", + "application/atom+xml", + "application/wasm", + "image/svg+xml", + "font/ttf", + "font/otf", +} + +// nginxModuleRuntimeDefaults maps a module to the http-context directives that +// make it actually do something once loaded. Without these, enabling a module +// only emits load_module, leaving it loaded but inert. +// +// brotli_static is intentionally omitted: nginx does not verify that a .br +// file is newer than its source, so a stale artifact would be served +// indefinitely with no error. +var nginxModuleRuntimeDefaults = map[string][]nginxHTTPDirective{ + "ngx_brotli": { + {Name: "brotli", Params: []string{"on"}}, + // Brotli level 5 reaches roughly gzip level 9 ratio at a fraction of + // the cost. The nginx default of 6 is tuned for static assets and is + // too expensive for dynamic responses. + {Name: "brotli_comp_level", Params: []string{"5"}}, + {Name: "brotli_min_length", Params: []string{"1k"}}, + {Name: "brotli_types", Params: nginxCompressibleTypes}, + }, +} + +// nginxModuleRuntimeLoadOrder keeps managed file names stable and ordered +// independently of the module load order used for load_module. +var nginxModuleRuntimeLoadOrder = map[string]int{ + "ngx_brotli": 100, +} + +func nginxModuleRuntimeOrder(name string) int { + if order, ok := nginxModuleRuntimeLoadOrder[name]; ok { + return order + } + return 900 +} + +// desiredNginxModuleRuntimeConfigs renders the managed http.d files for every +// enabled module that has a ready build and known runtime defaults. +// +// Values the user changed through the compression settings page are read back +// from the current managed file, so reconciling after an unrelated module +// change does not silently reset them to the defaults. +// +// A module the user already configured by hand in nginx.conf is skipped +// entirely. Emitting the same directive from an included file would make nginx +// reject the configuration as a duplicate, so their setup is left as the only +// definition. +func desiredNginxModuleRuntimeConfigs(install model.AppInstall, modules []dto.NginxModule, target dto.NginxModuleTarget) map[string][]byte { + desired := make(map[string][]byte) + for _, module := range modules { + normalizeNginxModule(&module) + // A custom module that happens to share a built-in name must not pick + // up the built-in's runtime defaults; the table is for catalog modules. + if module.Custom { + continue + } + directives, ok := nginxModuleRuntimeDefaults[module.Name] + if !ok || !module.Enable { + continue + } + if !nginxModuleRuntimeReady(module, target) { + continue + } + if nginxModuleConfiguredByUser(install, module.Name) { + continue + } + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(module.Name), module.Name) + current := readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName)) + desired[fileName] = renderNginxHTTPConfig(mergeNginxRuntimeDirectives(directives, current)) + } + return desired +} + +// nginxModuleConfiguredByUser reports whether the user already manages any of +// the module's directives by hand. +// +// Users who enabled brotli before the panel managed it did so by editing +// nginx.conf or a file it includes. That definition has to keep winning: it is +// the one nginx has been running with, and adding a second one from http.d +// would break the configuration outright. +// +// Any brotli* directive counts, not just the primary one. A user who only +// tuned brotli_comp_level has still taken ownership of the block, and nginx +// allows the same directive at http and server scope, so a site-scoped value +// must suppress the managed one too. +func nginxModuleConfiguredByUser(install model.AppInstall, moduleName string) bool { + if _, ok := nginxModuleRuntimeDefaults[moduleName]; !ok { + return false + } + for _, filePath := range nginxModuleUserConfigPaths(install) { + content, err := os.ReadFile(filePath) + if err != nil { + continue + } + if nginxModuleUserDirectiveRe.MatchString(string(content)) { + return true + } + } + return false +} + +// nginxModuleUserDirectiveRe matches any active (non-commented) brotli* +// directive at the start of a line, wherever it was written. +var nginxModuleUserDirectiveRe = regexp.MustCompile(`(?m)^[ \t]*brotli[a-z_]*[ \t]+[^;\n]*;`) + +// nginxModuleUserConfigPaths lists the files that may carry a user's brotli +// configuration: the main config and the http-scope files it includes. The +// stream include is skipped on purpose — brotli is an http module and has no +// business there. +func nginxModuleUserConfigPaths(install model.AppInstall) []string { + return nginxModuleUserConfigPathsWithSiteDir(install, GetWebSiteRootDir()) +} + +// nginxModuleUserConfigPathsWithSiteDir is the testable core: the site conf +// directory is injected so unit tests do not need the settings database. +func nginxModuleUserConfigPathsWithSiteDir(install model.AppInstall, siteDir string) []string { + paths := []string{nginxMainConfigPath(install)} + paths = append(paths, globConfFiles(path.Join(siteDir, "conf.d"))...) + paths = append(paths, globConfFiles(path.Join(install.GetPath(), nginxModuleConfDir, "default"))...) + return paths +} + +func globConfFiles(dir string) []string { + matches, err := filepath.Glob(path.Join(dir, "*.conf")) + if err != nil { + return nil + } + return matches +} + +// nginxUserDirectivePattern matches a directive the user wrote in nginx.conf, +// capturing its indentation so a rewrite can keep the line's shape. Leading +// whitespace only, so a commented-out line never matches. +func nginxUserDirectivePattern(name string) *regexp.Regexp { + return regexp.MustCompile(`(?m)^([ \t]*)` + regexp.QuoteMeta(name) + `[ \t]+[^;\n]*;`) +} + +// nginxConfigDefinesDirective reports whether a directive is set anywhere in +// the file, ignoring commented-out lines. +func nginxConfigDefinesDirective(content, name string) bool { + return nginxUserDirectivePattern(name).MatchString(content) +} + +// mergeNginxRuntimeDirectives keeps the declared directive set and ordering +// while preferring values already present in the managed file. +func mergeNginxRuntimeDirectives(defaults []nginxHTTPDirective, current map[string][]string) []nginxHTTPDirective { + if len(current) == 0 { + return defaults + } + merged := make([]nginxHTTPDirective, 0, len(defaults)) + for _, directive := range defaults { + if params, ok := current[directive.Name]; ok && len(params) > 0 { + directive.Params = params + } + merged = append(merged, directive) + } + return merged +} + +// nginxBrotliModuleName is the catalog name of the brotli module. +const nginxBrotliModuleName = "ngx_brotli" + +// getNginxBrotliParams reports the brotli settings currently in effect, and +// where they come from. +// +// Brotli is normally served from the managed http.d file instead of +// nginx.conf, so the directives can be removed together with the module. When +// the module is disabled the declared defaults are returned, which lets the +// settings page show what would be applied once it is enabled. +// +// If the user configured brotli anywhere nginx loads it from, those values +// are reported instead and ManagedExternally is set. Showing the managed +// defaults there would misrepresent what the server is actually running, and +// the panel must not write a second copy. +func getNginxBrotliParams() (*response.NginxBrotliRes, error) { + install, err := getAppInstallByKey(constant.AppOpenresty) + if err != nil { + return nil, err + } + managedExternally := nginxModuleConfiguredByUser(install, nginxBrotliModuleName) + var current map[string][]string + if managedExternally { + current = readNginxUserBrotliDirectives(install) + } else { + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + current = readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName)) + } + res := &response.NginxBrotliRes{ + ManagedExternally: managedExternally, + // Without the include, values the panel would write would never reach + // nginx, so they are reported as unavailable rather than shown as if + // they were in effect. + ManagedUnavailable: !managedExternally && !nginxHTTPIncludePresent(install), + } + for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) { + res.Params = append(res.Params, response.NginxParam{Name: directive.Name, Params: directive.Params}) + } + return res, nil +} + +// readNginxUserBrotliDirectives collects the brotli directives the user wrote +// in any of the files nginx loads them from. +func readNginxUserBrotliDirectives(install model.AppInstall) map[string][]string { + directives := make(map[string][]string) + for _, filePath := range nginxModuleUserConfigPaths(install) { + content, err := os.ReadFile(filePath) + if err != nil { + continue + } + for _, name := range dto.BrotliKeys { + pattern := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(name) + `[ \t]+([^;\n]*);`) + if match := pattern.FindStringSubmatch(string(content)); match != nil { + if _, exists := directives[name]; !exists { + directives[name] = strings.Fields(strings.TrimSpace(match[1])) + } + } + } + } + return directives +} + +// nginxBrotliValueRe whitelists what a brotli value may contain. The values +// are written into nginx.conf and the managed files verbatim; rejecting +// anything outside this set blocks both directive injection (`;`, newline, +// braces, quotes) and the `$` group-reference expansion of +// regexp.ReplaceAllString, which the in-place rewrite uses. +var nginxBrotliValueRe = regexp.MustCompile(`^[a-zA-Z0-9._+\-/:* ]+$`) + +// validateNginxBrotliValues rejects any value outside the whitelist. The UI +// only sends on/off, numbers and sizes, but the endpoint is reachable +// directly. +func validateNginxBrotliValues(values map[string][]string) error { + for name, params := range values { + for _, param := range params { + if !nginxBrotliValueRe.MatchString(param) { + return buserr.WithDetail("ErrInvalidParams", fmt.Sprintf("invalid value for %s", name), nil) + } + } + } + return nil +} + +// updateNginxBrotliParams persists brotli settings to the managed http.d file. +// +// Writing is refused unless the module is enabled and built: the directives +// would reference a module that is not loaded and nginx would fail to start. +func updateNginxBrotliParams(params []dto.NginxParam) error { + install, err := getAppInstallByKey(constant.AppOpenresty) + if err != nil { + return err + } + modules, err := loadNginxModules(install) + if err != nil { + return err + } + values := make(map[string][]string, len(params)) + for _, param := range params { + values[param.Name] = param.Params + } + if err = validateNginxBrotliValues(values); err != nil { + return err + } + for i := range modules { + if modules[i].Name != nginxBrotliModuleName { + continue + } + if !modules[i].Enable { + return buserr.New("ErrBrotliDisabled") + } + // The user configured brotli in nginx.conf before the panel managed + // it. Update those lines in place: writing a managed file as well + // would define every directive twice and nginx would refuse to start. + if nginxModuleConfiguredByUser(install, nginxBrotliModuleName) { + return updateUserNginxBrotliParams(install, values) + } + // A managed write needs the include. Installations missing it are + // upgraded in place here; when nginx.conf cannot be edited safely the + // write is refused with an actionable error instead of writing values + // nginx would never load. + if !nginxHTTPIncludePresent(install) { + configPath := nginxMainConfigPath(install) + content, readErr := os.ReadFile(configPath) + if readErr != nil { + return readErr + } + updated, insErr := insertNginxHTTPInclude(string(content)) + if insErr != nil { + return buserr.New("ErrBrotliUnsupported") + } + if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil { + return err + } + if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil { + return err + } + if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil { + return err + } + } + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + configDir := nginxHTTPConfigDir(install) + snapshot, snapErr := snapshotManagedNginxHTTPConfigs(configDir) + if snapErr != nil { + return snapErr + } + merged := mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], values) + desired := map[string][]byte{fileName: renderNginxHTTPConfig(merged)} + for name, content := range snapshot { + if name != fileName { + desired[name] = content + } + } + if err = applyManagedNginxHTTPConfigs(configDir, desired); err != nil { + _ = applyManagedNginxHTTPConfigs(configDir, snapshot) + return err + } + if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { + _ = applyManagedNginxHTTPConfigs(configDir, snapshot) + return err + } + if err = opNginx(install.ContainerName, constant.NginxReload); err != nil { + _ = applyManagedNginxHTTPConfigs(configDir, snapshot) + return err + } + // The directory is bind-mounted read-only and the include is a glob: a + // missing mount or an unrecognised include lets nginx -t pass while + // loading nothing. Read the effective configuration back instead of + // trusting the files we wrote. + if err = assertNginxBrotliActive(install.ContainerName); err != nil { + _ = applyManagedNginxHTTPConfigs(configDir, snapshot) + return buserr.New("ErrBrotliUnsupported") + } + return nil + } + return buserr.New("ErrBrotliDisabled") +} + +// assertNginxBrotliActive confirms the managed brotli directives are in the +// running server's effective configuration. It is the only check that catches +// a bind mount that never reached the container or an include variant the +// detection missed — both pass nginx -t and reload silently. +func assertNginxBrotliActive(containerName string) error { + out, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout( + "docker", "exec", "-i", containerName, "nginx", "-T") + if err != nil { + return err + } + if !nginxModuleUserDirectiveRe.MatchString(out) { + return errors.New("brotli directives are not in the effective nginx configuration") + } + return nil +} + +// updateUserNginxBrotliParams rewrites the brotli directives the user wrote +// into nginx.conf, in place. +// +// Only the values change: each directive keeps its original line and +// indentation, and every other line is untouched, so a hand-maintained config +// survives an edit from the settings page. Directives the user did not write +// are not introduced, since the panel cannot know where they intended them. +// +// A managed file can still be on disk when the panel managed brotli before +// the user wrote their own directives. Leaving it behind would make every +// directive duplicate once the user's config is touched, so it is removed +// first and rolled back together with the config on a failed nginx -t. +func updateUserNginxBrotliParams(install model.AppInstall, values map[string][]string) error { + configPath := nginxMainConfigPath(install) + content, err := os.ReadFile(configPath) + if err != nil { + return err + } + configDir := nginxHTTPConfigDir(install) + httpSnapshot, snapErr := snapshotManagedNginxHTTPConfigs(configDir) + if snapErr != nil { + return snapErr + } + managedFile := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + if _, stale := httpSnapshot[managedFile]; stale { + remaining := make(map[string][]byte, len(httpSnapshot)) + for name, fileContent := range httpSnapshot { + if name != managedFile { + remaining[name] = fileContent + } + } + if err = applyManagedNginxHTTPConfigs(configDir, remaining); err != nil { + return err + } + } + restore := func() { + _ = writeNginxFileAtomic(configPath, content) + _ = applyManagedNginxHTTPConfigs(configDir, httpSnapshot) + } + + updated := string(content) + for _, name := range dto.BrotliKeys { + params, ok := values[name] + if !ok || len(params) == 0 { + continue + } + pattern := nginxUserDirectivePattern(name) + if !pattern.MatchString(updated) { + continue + } + replacement := "${1}" + name + " " + strings.Join(params, " ") + ";" + updated = pattern.ReplaceAllString(updated, replacement) + } + if updated == string(content) { + return nil + } + if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil { + restore() + return err + } + if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { + restore() + return err + } + if err = opNginx(install.ContainerName, constant.NginxReload); err != nil { + restore() + return err + } + return nil +} + +// nginxModuleRuntimeReady reports whether the module is actually usable. +// +// Dynamic modules need a ready build for the current target, otherwise the +// .so is missing and nginx would reject the directives. Static modules are +// compiled into the binary and carry no artifacts, so an enabled static +// module is considered ready. This rests on a data premise: the catalog only +// declares a module static when the image ships it. Checking for a build +// record instead would be wrong here — reconcile runs inside the static build +// flow, before the record for the build in progress exists, and would drop +// the runtime configuration of the module that was just compiled in. +func nginxModuleRuntimeReady(module dto.NginxModule, target dto.NginxModuleTarget) bool { + if module.BuildMode == nginxModuleBuildStatic { + return true + } + build := findCurrentNginxModuleBuild(module, target) + if build == nil || build.Status != nginxModuleStatusReady { + build = findLatestNginxModuleBuild(module, target) + } + return build != nil && build.Status == nginxModuleStatusReady +} diff --git a/agent/app/service/nginx_module_runtime_test.go b/agent/app/service/nginx_module_runtime_test.go new file mode 100644 index 000000000000..8be49c9739c0 --- /dev/null +++ b/agent/app/service/nginx_module_runtime_test.go @@ -0,0 +1,231 @@ +package service + +import ( + "os" + "path" + "strings" + "testing" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" +) + +const userBrotliConf = `user root; +worker_processes auto; + +include /usr/local/openresty/nginx/conf/modules-enabled/*.conf; + +events { use epoll; } + +http { + include mime.types; + default_type application/octet-stream; + + gzip on; + gzip_comp_level 5; + + # enabled by hand, long before the panel managed it + brotli on; + brotli_comp_level 6; + brotli_types text/plain text/css application/json; + + include /usr/local/openresty/nginx/conf/http.d/*.conf; + include /usr/local/openresty/nginx/conf/conf.d/*.conf; +} +` + +func TestNginxConfigDefinesDirective(t *testing.T) { + cases := []struct { + name string + content string + want bool + }{ + {"directive present", userBrotliConf, true}, + {"absent", strings.Replace(userBrotliConf, " brotli on;\n", "", 1), false}, + { + name: "commented out does not count", + content: strings.Replace(userBrotliConf, " brotli on;", " # brotli on;", 1), + want: false, + }, + { + name: "a longer directive name is not a match", + content: "http {\n brotli_comp_level 6;\n}\n", + want: false, + }, + { + name: "indentation does not matter", + content: "http {\n\t\tbrotli on;\n}\n", + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nginxConfigDefinesDirective(tc.content, "brotli"); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +// The detection must catch any brotli* directive, in any file nginx loads it +// from, not only the primary directive in nginx.conf. +func TestNginxModuleUserDirectiveRe(t *testing.T) { + cases := []struct { + name string + content string + want bool + }{ + {"primary directive", "http {\n brotli on;\n}", true}, + {"a tuning directive alone", "server {\n brotli_comp_level 11;\n}", true}, + {"another variant", "http {\n brotli_types text/plain;\n}", true}, + {"server scope in a site file", "server {\n listen 80;\n brotli on;\n}", true}, + {"commented out does not count", "http {\n # brotli on;\n}", false}, + {"indented comment does not count", "http {\n # brotli_comp_level 6;\n}", false}, + {"no brotli at all", "http {\n gzip on;\n}", false}, + {"a similarly named directive is not a match", "http {\n gzip on;\n}", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nginxModuleUserDirectiveRe.MatchString(tc.content); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +// The panel must not emit a managed file for a module the user already +// configured: nginx rejects the same directive defined twice. +func TestUserConfiguredBrotliSuppressesManagedFile(t *testing.T) { + if !nginxModuleUserDirectiveRe.MatchString(userBrotliConf) { + t.Fatal("a hand-written brotli config must be detected") + } + clean := strings.Replace(userBrotliConf, " brotli on;\n", "", 1) + clean = strings.Replace(clean, " brotli_comp_level 6;\n", "", 1) + clean = strings.Replace(clean, " brotli_types text/plain text/css application/json;\n", "", 1) + if nginxModuleUserDirectiveRe.MatchString(clean) { + t.Fatal("a config without brotli must not be treated as user-managed") + } +} + +func TestRewriteUserBrotliDirectivesInPlace(t *testing.T) { + // Mirrors updateUserNginxBrotliParams without touching the filesystem. + rewrite := func(content string, values map[string][]string) string { + updated := content + for _, name := range []string{"brotli", "brotli_comp_level", "brotli_min_length", "brotli_types"} { + params, ok := values[name] + if !ok || len(params) == 0 { + continue + } + pattern := nginxUserDirectivePattern(name) + if !pattern.MatchString(updated) { + continue + } + updated = pattern.ReplaceAllString(updated, "${1}"+name+" "+strings.Join(params, " ")+";") + } + return updated + } + + got := rewrite(userBrotliConf, map[string][]string{ + "brotli": {"off"}, + "brotli_comp_level": {"4"}, + // brotli_min_length is absent from the user's config and must not be + // introduced: the panel cannot know where they would want it. + "brotli_min_length": {"2k"}, + }) + + if !strings.Contains(got, " brotli off;") { + t.Errorf("value was not updated:\n%s", got) + } + if !strings.Contains(got, " brotli_comp_level 4;") { + t.Errorf("comp level was not updated:\n%s", got) + } + if strings.Contains(got, "brotli_min_length") { + t.Error("a directive the user never wrote must not be added") + } + // Everything else survives, including the comment the parser would drop. + for _, keep := range []string{ + "# enabled by hand, long before the panel managed it", + " gzip on;", + " gzip_comp_level 5;", + " brotli_types text/plain text/css application/json;", + "include /usr/local/openresty/nginx/conf/conf.d/*.conf;", + "worker_processes auto;", + } { + if !strings.Contains(got, keep) { + t.Errorf("unrelated line was altered or lost: %s", keep) + } + } + if strings.Count(got, "brotli on;")+strings.Count(got, "brotli off;") != 1 { + t.Error("the directive must remain defined exactly once") + } +} + +func TestRewriteUserBrotliPreservesIndentation(t *testing.T) { + content := "http {\n\t\tbrotli on;\n}\n" + pattern := nginxUserDirectivePattern("brotli") + got := pattern.ReplaceAllString(content, "${1}brotli off;") + if !strings.Contains(got, "\t\tbrotli off;") { + t.Errorf("original indentation was not preserved: %q", got) + } +} + +// Detection must cover the default/ directory, which is included at http +// scope like conf.d but lives under the install directory, not the site root. +func TestNginxModuleUserConfigPathsCoversDefaultDir(t *testing.T) { + siteDir := t.TempDir() + installRoot := t.TempDir() + installDir := path.Join(installRoot, "openresty", "openresty") + defaultDir := path.Join(installDir, "conf", "default") + if err := os.MkdirAll(defaultDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path.Join(defaultDir, "00.default.conf"), []byte("server {}\n"), 0o644); err != nil { + t.Fatal(err) + } + global.Dir.AppInstallDir = installRoot + install := model.AppInstall{Name: "openresty"} + install.App.Key = constant.AppOpenresty + + paths := nginxModuleUserConfigPathsWithSiteDir(install, siteDir) + foundMain, foundDefault := false, false + for _, p := range paths { + if strings.HasSuffix(p, path.Join("conf", "nginx.conf")) { + foundMain = true + } + if strings.HasSuffix(p, path.Join("conf", "default", "00.default.conf")) { + foundDefault = true + } + } + if !foundMain { + t.Error("nginx.conf must be scanned") + } + if !foundDefault { + t.Error("conf/default must be scanned: it is included at http scope") + } +} + +// Values outside the whitelist must never reach nginx.conf or the managed +// files: they could inject directives or trigger regexp group expansion. +func TestValidateNginxBrotliValues(t *testing.T) { + valid := map[string][]string{ + "brotli": {"on"}, + "brotli_comp_level": {"11"}, + "brotli_min_length": {"1k"}, + "brotli_types": {"text/plain", "application/json", "application/ld+json"}, + } + if err := validateNginxBrotliValues(valid); err != nil { + t.Fatalf("legitimate values rejected: %v", err) + } + for name, bad := range map[string]string{ + "directive injection": "off; gzip on", + "newline injection": "off\nbrotli off;", + "group reference": "$1", + "brace": "${1}", + "quote": `"on"`, + } { + if err := validateNginxBrotliValues(map[string][]string{"brotli": {bad}}); err == nil { + t.Errorf("%s: %q must be rejected", name, bad) + } + } +} diff --git a/agent/app/service/nginx_module_static_test.go b/agent/app/service/nginx_module_static_test.go new file mode 100644 index 000000000000..d4fcc61f606d --- /dev/null +++ b/agent/app/service/nginx_module_static_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "testing" + + "github.com/1Panel-dev/1Panel/agent/app/dto" +) + +func TestHasEnabledStaticNginxModules(t *testing.T) { + cases := []struct { + name string + modules []dto.NginxModule + want bool + }{ + { + name: "an enabled static module requires a rebuild", + modules: []dto.NginxModule{ + {Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic}, + }, + want: true, + }, + { + name: "a disabled static module does not", + modules: []dto.NginxModule{ + {Name: "custom", Enable: false, BuildMode: nginxModuleBuildStatic}, + }, + want: false, + }, + { + name: "dynamic modules never require a rebuild", + modules: []dto.NginxModule{ + {Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic}, + }, + want: false, + }, + { + name: "no modules at all", + modules: nil, + want: false, + }, + { + name: "one enabled static module among dynamic ones is enough", + modules: []dto.NginxModule{ + {Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic}, + {Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic}, + }, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hasEnabledStaticNginxModules(tc.modules); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +// A stale RESTY_CONFIG_OPTIONS_MORE used to force the full-rebuild path even +// with no static module enabled. configureStaticNginxModules derives that value +// from the module list and runs before every build, so the rebuild it triggered +// could only ever reproduce the current image. Module state is now the only +// input; this test pins that down. +func TestStaticRebuildIgnoresLeftoverBuildOptions(t *testing.T) { + modules := []dto.NginxModule{ + {Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic}, + } + if hasEnabledStaticNginxModules(modules) { + t.Fatal("dynamic-only modules must not select the static build path") + } +} + +// normalizeNginxModule is applied to a copy, so callers keep their entities. +func TestHasEnabledStaticNginxModulesDoesNotMutateInput(t *testing.T) { + modules := []dto.NginxModule{ + {Name: "custom", Enable: true, BuildMode: nginxModuleBuildStatic, Packages: []string{"", "libfoo", ""}}, + } + _ = hasEnabledStaticNginxModules(modules) + if len(modules[0].Packages) != 3 { + t.Fatalf("input was normalized in place: %v", modules[0].Packages) + } +} + diff --git a/agent/cmd/server/nginx_conf/gzip.conf b/agent/cmd/server/nginx_conf/gzip.conf index b277f1f890df..27d27a84f109 100644 --- a/agent/cmd/server/nginx_conf/gzip.conf +++ b/agent/cmd/server/nginx_conf/gzip.conf @@ -1,4 +1,8 @@ gzip on; -gzip_comp_level 6; +gzip_vary on; gzip_min_length 1k; -gzip_types text/plain text/css text/xml text/javascript text/x-component application/json application/javascript application/x-javascript application/xml application/xhtml+xml application/rss+xml application/atom+xml application/x-font-ttf application/vnd.ms-fontobject image/svg+xml image/x-icon font/opentype; \ No newline at end of file +gzip_buffers 4 16k; +gzip_http_version 1.1; +gzip_comp_level 5; +gzip_proxied any; +gzip_types text/plain text/css text/xml text/javascript application/json application/ld+json application/javascript application/x-javascript application/xml application/xhtml+xml application/rss+xml application/atom+xml application/wasm image/svg+xml font/ttf font/otf; diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 9af2b721a0df..1d20d2dae137 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'Invalid domain format: {{ .name }}' ErrDefaultAlias: 'default is reserved; use another alias' ErrParentWebsite: 'Delete subsite {{ .name }} first' ErrBuildDirNotFound: 'The build directory does not exist' +ErrBrotliDisabled: 'The brotli module is not enabled, enable and build it first' +ErrBrotliUnsupported: 'The panel could not wire brotli settings into nginx.conf automatically; check the file or upgrade OpenResty' +ErrModuleBuildUnsupported: 'This OpenResty version cannot build modules, upgrade it first' ErrImageNotExist: 'Runtime image not found: {{ .name }}' ErrProxyIsUsed: 'Load balancer is used by reverse proxy' ErrSSLValid: 'Certificate file is invalid' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 586d6466c049..af97424fd212 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'El formato del dominio {{ .name }} es incorrecto' ErrDefaultAlias: 'default es un código reservado, use otro' ErrParentWebsite: 'Primero debe eliminar el sub-sitio {{ .name }}' ErrBuildDirNotFound: 'El directorio de compilación no existe' +ErrBrotliDisabled: 'El módulo brotli no está habilitado, actívelo y compílelo primero' +ErrBrotliUnsupported: 'El panel no pudo conectar automáticamente la configuración brotli a nginx.conf; revise el archivo o actualice OpenResty' +ErrModuleBuildUnsupported: 'Esta versión de OpenResty no puede compilar módulos, actualícela primero' ErrImageNotExist: 'La imagen del entorno {{ .name }} no existe, edítela de nuevo' ErrProxyIsUsed: 'El balanceo de carga ya está usado por un proxy reverso, no se puede eliminar' ErrSSLValid: 'Archivo de certificado anómalo, revise el estado del certificado' diff --git a/agent/i18n/lang/fa.yaml b/agent/i18n/lang/fa.yaml index 32d1bb561488..41d091b834a1 100644 --- a/agent/i18n/lang/fa.yaml +++ b/agent/i18n/lang/fa.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'فرمت دامنه نامعتبر است: {{ .name }}' ErrDefaultAlias: 'default رزرو شده است؛ از نام مستعار دیگری استفاده کنید' ErrParentWebsite: 'ابتدا زیرسایت {{ .name }} را حذف کنید' ErrBuildDirNotFound: 'دایرکتوری ساخت وجود ندارد' +ErrBrotliDisabled: 'ماژول brotli فعال نیست، ابتدا آن را فعال و بیلد کنید' +ErrBrotliUnsupported: 'پنل نتوانست تنظیمات brotli را به‌صورت خودکار به nginx.conf متصل کند؛ فایل را بررسی کنید یا OpenResty را ارتقا دهید' +ErrModuleBuildUnsupported: 'این نسخه OpenResty نمی‌تواند ماژول بسازد، ابتدا آن را ارتقا دهید' ErrImageNotExist: 'تصویر محیط اجرا یافت نشد: {{ .name }}' ErrProxyIsUsed: 'تعادل بار توسط پراکسی معکوس استفاده می‌شود' ErrSSLValid: 'فایل گواهی نامعتبر است' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index bb4d1a1a0389..92268ab048b9 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: '{{ .name }} ドメイン名の形式が正しくありませ ErrDefaultAlias: 'デフォルトは予約済みのコードです。別のコードを使用してください' ErrParentWebsite: 'まずサブサイト {{ .name }} を削除する必要があります' ErrBuildDirNotFound: 'ビルド ディレクトリが存在しません' +ErrBrotliDisabled: 'brotli モジュールが有効になっていません。先に有効化してビルドしてください' +ErrBrotliUnsupported: 'パネルは brotli 設定を nginx.conf に自動で組み込めませんでした。ファイルを確認するか OpenResty をアップグレードしてください' +ErrModuleBuildUnsupported: 'この OpenResty バージョンではモジュールをビルドできません。先にアップグレードしてください' ErrImageNotExist: 'オペレーティング環境 {{ .name }} イメージが存在しません。オペレーティング環境を再編集してください' ErrProxyIsUsed: 'ロードバランシングはリバースプロキシによって使用されているため、削除できません' ErrSSLValid: '証明書ファイルが異常です、証明書の状態を確認してください!' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index f47378a22c45..8b3bc485a7ff 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: '{{ .name }} 도메인 이름 형식이 올바르지 않습니 ErrDefaultAlias: '기본값은 예약된 코드입니다. 다른 코드를 사용하세요' ErrParentWebsite: '먼저 하위 사이트 {{ .name }}을 삭제해야 합니다.' ErrBuildDirNotFound: '빌드 디렉토리가 존재하지 않습니다' +ErrBrotliDisabled: 'brotli 모듈이 활성화되지 않았습니다. 먼저 활성화하고 빌드하세요' +ErrBrotliUnsupported: '패널이 brotli 설정을 nginx.conf에 자동으로 연결할 수 없습니다. 파일을 확인하거나 OpenResty를 업그레이드하세요' +ErrModuleBuildUnsupported: '현재 OpenResty 버전은 모듈을 빌드할 수 없습니다. 먼저 업그레이드하세요' ErrImageNotExist: '운영 환경 {{ .name }} 이미지가 존재하지 않습니다. 운영 환경을 다시 편집하세요.' ErrProxyIsUsed: '로드 밸런싱이 역방향 프록시에 의해 사용되었으므로 삭제할 수 없습니다' ErrSSLValid: '인증서 파일에 문제가 있습니다. 인증서 상태를 확인하세요' diff --git a/agent/i18n/lang/lo.yaml b/agent/i18n/lang/lo.yaml index 98394f4784cd..ce9f39078608 100644 --- a/agent/i18n/lang/lo.yaml +++ b/agent/i18n/lang/lo.yaml @@ -205,6 +205,9 @@ ErrDomainFormat: 'ຮູບແບບໂດເມນບໍ່ຖືກຕ້ອ ErrDefaultAlias: 'ຊື່ default ຖືກສະຫງວນໄວ້; ກະລຸນາໃຊ້ຊື່ອື່ນ' ErrParentWebsite: 'ກະລຸນາລຶບເວັບໄຊຍ່ອຍ {{ .name }} ກ່ອນ' ErrBuildDirNotFound: 'ບໍ່ມີໂຟນເດີ Build ຢູ່' +ErrBrotliDisabled: 'ໂມດູນ brotli ຍັງບໍ່ໄດ້ເປີດໃຊ້, ກະລຸນາເປີດໃຊ້ ແລະ ສ້າງກ່ອນ' +ErrBrotliUnsupported: 'ແຜງຄວບຄຸມບໍ່ສາມາດເຊື່ອມການຕັ້ງຄ່າ brotli ເຂົ້າ nginx.conf ໂດຍອັດຕະໂນມັດໄດ້; ກວດເບິ່ງໄຟລ໌ ຫຼື ອັບເກຣດ OpenResty' +ErrModuleBuildUnsupported: 'ລຸ້ນ OpenResty ນີ້ບໍ່ສາມາດສ້າງໂມດູນໄດ້, ກະລຸນາອັບເກຣດກ່ອນ' ErrImageNotExist: 'ບໍ່ພົບຮູບພາບ runtime: {{ .name }}' ErrProxyIsUsed: 'ຕົວຈັດການການໂຫຼດ (Load balancer) ຖືກໃຊ້ງານໂດຍ reverse proxy' ErrSSLValid: 'ໄຟລ໌ໃບຮັບຮອງບໍ່ຖືກຕ້ອງ' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index cc9ac8aab68a..232697ff829e 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'Format nama domain {{ .name }} tidak betul' ErrDefaultAlias: 'lalai ialah kod simpanan, sila gunakan kod lain' ErrParentWebsite: 'Anda perlu memadamkan subtapak {{ .name }} dahulu' ErrBuildDirNotFound: 'Direktori binaan tidak wujud' +ErrBrotliDisabled: 'Modul brotli tidak diaktifkan, aktifkan dan bina dahulu' +ErrBrotliUnsupported: 'Panel tidak dapat menyambungkan tetapan brotli ke nginx.conf secara automatik; semak fail atau naik taraf OpenResty' +ErrModuleBuildUnsupported: 'Versi OpenResty ini tidak boleh membina modul, naik taraf dahulu' ErrImageNotExist: 'Imej persekitaran operasi {{ .name }} tidak wujud, sila edit semula persekitaran pengendalian' ErrProxyIsUsed: 'Pengimbang beban telah digunakan oleh pengganti terbalik, tidak boleh dipadamkan' ErrSSLValid: 'Fail sijil bermasalah, sila periksa status sijil' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index 24deaf4eeecd..258321ad8a07 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'o formato do nome de domínio {{ .name }} está incorreto' ErrDefaultAlias: 'padrão é um código reservado, use outro código' ErrParentWebsite: 'Você precisa excluir o subsite {{ .name }} primeiro' ErrBuildDirNotFound: 'O diretório de compilação não existe' +ErrBrotliDisabled: 'O módulo brotli não está ativado, ative-o e compile-o primeiro' +ErrBrotliUnsupported: 'O painel não conseguiu conectar as configurações brotli ao nginx.conf automaticamente; verifique o arquivo ou atualize o OpenResty' +ErrModuleBuildUnsupported: 'Esta versão do OpenResty não pode compilar módulos, atualize-a primeiro' ErrImageNotExist: 'A imagem do ambiente operacional {{ .name }} não existe, edite novamente o ambiente operacional' ErrProxyIsUsed: 'Balanceamento de carga foi usado por proxy reverso, não pode ser excluído' ErrSSLValid: 'O arquivo do certificado está anormal, verifique o status do certificado' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index b9cabd88dfb8..742ddc8a698f 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: 'Неверный формат доменного имени {{ ErrDefaultAlias: 'по умолчанию зарезервирован код, используйте другой код' ErrParentWebsite: 'Сначала вам необходимо удалить дочерний сайт {{ .name }}' ErrBuildDirNotFound: 'Каталог сборки не существует' +ErrBrotliDisabled: 'Модуль brotli не включён, сначала включите и соберите его' +ErrBrotliUnsupported: 'Панель не смогла автоматически подключить настройки brotli к nginx.conf; проверьте файл или обновите OpenResty' +ErrModuleBuildUnsupported: 'Эта версия OpenResty не может собирать модули, сначала обновите её' ErrImageNotExist: 'Образ операционной среды {{ .name }} не существует, пожалуйста, отредактируйте операционную среду заново' ErrProxyIsUsed: 'Балансировка нагрузки используется обратным прокси, невозможно удалить' ErrSSLValid: 'Файл сертификата аномален, проверьте статус сертификата' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index c71d97454e94..05f3041386ee 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: '{{ .name }} alan adı formatı yanlış' ErrDefaultAlias: 'default ayrılmış bir kod, lütfen başka bir kod kullanın' ErrParentWebsite: 'Önce {{ .name }} alt sitesini silmeniz gerekiyor' ErrBuildDirNotFound: 'Yapı dizini mevcut değil' +ErrBrotliDisabled: 'brotli modülü etkin değil, önce etkinleştirin ve derleyin' +ErrBrotliUnsupported: 'Panel brotli ayarlarını nginx.conf dosyasına otomatik olarak bağlayamadı; dosyayı kontrol edin veya OpenResty yi yükseltin' +ErrModuleBuildUnsupported: 'Bu OpenResty sürümü modül derleyemez, önce yükseltin' ErrImageNotExist: 'İşletim ortamı {{ .name }} image mevcut değil, lütfen işletim ortamını yeniden düzenleyin' ErrProxyIsUsed: 'Yük dengeleme ters proxy tarafından kullanıldı, silinemez' ErrSSLValid: 'Sertifika dosyası anormal, lütfen sertifika durumunu kontrol edin' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index 4cb9952d1097..abe8b3ea4e98 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: '{{ .name }} 網域格式不正確' ErrDefaultAlias: 'default 為保留代號,請使用其他代號' ErrParentWebsite: '需要先移除子網站{{ .name }}' ErrBuildDirNotFound: '建置目錄不存在' +ErrBrotliDisabled: 'brotli 模組未啟用,請先啟用並建置' +ErrBrotliUnsupported: '面板無法自動將 brotli 設定寫入 nginx.conf,請檢查設定檔或升級 OpenResty' +ErrModuleBuildUnsupported: '目前 OpenResty 版本無法建置模組,請先升級' ErrImageNotExist: '執行環境{{ .name }} 映像不存在,請重新編輯執行環境' ErrProxyIsUsed: '負載均衡已被反向代理使用,無法刪除' ErrSSLValid: '憑證檔案異常,請檢查憑證狀態!' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 77ec11697cac..de5dcb05f41b 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -215,6 +215,9 @@ ErrDomainFormat: "{{ .name }} 域名格式不正确" ErrDefaultAlias: "default 为保留代号,请使用其他代号" ErrParentWebsite: "需要先删除子网站 {{ .name }}" ErrBuildDirNotFound: "构建目录不存在" +ErrBrotliDisabled: "brotli 模块未启用,请先启用并构建" +ErrBrotliUnsupported: "面板无法自动将 brotli 配置写入 nginx.conf,请检查配置文件或升级 OpenResty" +ErrModuleBuildUnsupported: "当前 OpenResty 版本无法构建模块,请先升级" ErrImageNotExist: "运行环境 {{ .name }} 镜像不存在,请重新编辑运行环境" ErrProxyIsUsed: "负载均衡已被反向代理使用,无法删除" ErrSSLValid: '证书文件异常,请检查证书状态!' diff --git a/frontend/src/api/interface/nginx.ts b/frontend/src/api/interface/nginx.ts index 12f858863204..c2d08b96725f 100644 --- a/frontend/src/api/interface/nginx.ts +++ b/frontend/src/api/interface/nginx.ts @@ -7,6 +7,12 @@ export namespace Nginx { params: string[]; } + export interface NginxBrotliRes { + params: NginxParam[]; + managedExternally: boolean; + managedUnavailable: boolean; + } + export interface NginxConfigReq { operate: string; websiteId?: number; diff --git a/frontend/src/api/modules/nginx.ts b/frontend/src/api/modules/nginx.ts index b4120a18cdd9..d9d1e7f6f8ad 100644 --- a/frontend/src/api/modules/nginx.ts +++ b/frontend/src/api/modules/nginx.ts @@ -6,8 +6,8 @@ export const getNginx = () => { return http.get(`/openresty`); }; -export const getNginxConfigByScope = (req: Nginx.NginxScopeReq) => { - return http.post(`/openresty/scope`, req); +export const getNginxConfigByScope = (req: Nginx.NginxScopeReq) => { + return http.post(`/openresty/scope`, req); }; export const updateNginxConfigByScope = (req: Nginx.NginxConfigReq) => { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 49baa422f7c9..5620097e041f 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -3904,6 +3904,14 @@ const message = { gzipMinLengthHelper: 'Minimum Compressed File', gzipCompLevelHelper: 'Compression Rate', gzipHelper: 'Enable compression for transmission', + brotliHelper: 'Enable brotli compression, usually smaller than gzip', + brotliCompLevelHelper: 'Brotli compression rate, 0 to 11', + brotliManagedExternallyHelper: + 'Brotli is configured manually in nginx.conf; the panel shows the values in effect and will not overwrite them.', + brotliManagedUnavailableHelper: + 'The panel could not add the managed brotli configuration to nginx.conf automatically; the values below will not take effect.', + brotliMinLengthHelper: 'Minimum response size to brotli-compress', + brotliSaveFailed: 'Brotli settings failed to save; the gzip settings above were applied', connections: 'Active connections', accepts: 'Accepts', handled: 'Handled', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 5a45e87b0046..ebb3125a048a 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -3951,6 +3951,14 @@ const message = { gzipMinLengthHelper: 'Tamaño mínimo para comprimir', gzipCompLevelHelper: 'Nivel de compresión', gzipHelper: 'Habilitar compresión para transmisión', + brotliHelper: 'Habilitar compresión brotli, normalmente menor que gzip', + brotliCompLevelHelper: 'Tasa de compresión brotli, de 0 a 11', + brotliManagedExternallyHelper: + 'Brotli está configurado manualmente en nginx.conf; el panel muestra los valores en vigor y no los sobrescribe.', + brotliManagedUnavailableHelper: + 'El panel no pudo añadir automáticamente la configuración brotli gestionada a nginx.conf; los valores siguientes no tendrán efecto.', + brotliMinLengthHelper: 'Tamaño mínimo de respuesta para comprimir con brotli', + brotliSaveFailed: 'No se pudo guardar la configuración de brotli; la configuración gzip anterior se aplicó', connections: 'Conexiones activas', accepts: 'Aceptadas', handled: 'Gestionadas', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index f5dd35614745..a42bdd74852a 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -3871,6 +3871,14 @@ const message = { gzipMinLengthHelper: 'حداقل فایل فشرده', gzipCompLevelHelper: 'نرخ فشرده‌سازی', gzipHelper: 'فعال‌سازی فشرده‌سازی برای انتقال', + brotliHelper: 'فعال‌سازی فشرده‌سازی brotli، معمولاً کوچک‌تر از gzip', + brotliCompLevelHelper: 'نرخ فشرده‌سازی brotli، از 0 تا 11', + brotliManagedExternallyHelper: + 'brotli به‌صورت دستی در nginx.conf پیکربندی شده است؛ پنل فقط مقادیر در حال اجرا را نشان می‌دهد و آن‌ها را بازنویسی نمی‌کند.', + brotliManagedUnavailableHelper: + 'پنل نتوانست پیکربندی مدیریت‌شده brotli را به‌صورت خودکار به nginx.conf اضافه کند؛ مقادیر زیر اعمال نخواهند شد.', + brotliMinLengthHelper: 'حداقل اندازه پاسخ برای فشرده‌سازی با brotli', + brotliSaveFailed: 'ذخیره تنظیمات brotli ناموفق بود؛ تنظیمات gzip بالا اعمال شد', connections: 'اتصال‌های فعال', accepts: 'پذیرش‌ها', handled: 'مدیریت شده', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index e7cacbc98fa3..b5cbfdd68129 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -3894,6 +3894,14 @@ const message = { gzipMinLengthHelper: '最小圧縮ファイル', gzipCompLevelHelper: '圧縮率', gzipHelper: '伝送の圧縮を有効にします', + brotliHelper: 'brotli 圧縮を有効にします。通常 gzip より小さくなります', + brotliCompLevelHelper: 'brotli 圧縮率、0 から 11', + brotliManagedExternallyHelper: + 'brotli は nginx.conf で手動設定されています。パネルは現在の有効な値を表示するだけで、上書きしません。', + brotliManagedUnavailableHelper: + 'パネルは管理対象の brotli 設定を nginx.conf に自動で追加できませんでした。以下の設定は有効になりません。', + brotliMinLengthHelper: 'brotli で圧縮する最小レスポンスサイズ', + brotliSaveFailed: 'brotli 設定の保存に失敗しました。上記の gzip 設定は適用されました', connections: 'アクティブな接続', accepts: '受け入れます', handled: '処理', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 716aa7bb72dd..b5a84e0f87d1 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -3823,6 +3823,14 @@ const message = { gzipMinLengthHelper: '최소 압축 파일 크기', gzipCompLevelHelper: '압축률', gzipHelper: '전송을 위한 압축 활성화', + brotliHelper: 'brotli 압축 활성화, 일반적으로 gzip보다 작습니다', + brotliCompLevelHelper: 'brotli 압축률, 0에서 11까지', + brotliManagedExternallyHelper: + 'brotli가 nginx.conf에 수동으로 구성되어 있습니다. 패널은 적용 중인 값만 표시하며 덮어쓰지 않습니다.', + brotliManagedUnavailableHelper: + '패널이 관리되는 brotli 구성을 nginx.conf에 자동으로 추가할 수 없습니다. 아래 설정은 적용되지 않습니다.', + brotliMinLengthHelper: 'brotli로 압축할 최소 응답 크기', + brotliSaveFailed: 'brotli 설정 저장에 실패했습니다. 위의 gzip 설정은 적용되었습니다', connections: '활성 연결', accepts: '수락', handled: '처리됨', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index ab9080d57ec8..51c014a1d4a3 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -3798,6 +3798,14 @@ const message = { gzipMinLengthHelper: 'ຂະໜາດໄຟລ໌ຕ່ຳສຸດທີ່ຈະບີບອັດ', gzipCompLevelHelper: 'ອັດຕາການບີບອັດ', gzipHelper: 'ເປີດໃຊ້ການບີບອັດໃນການຮັບສົ່ງຂໍ້ມູນ', + brotliHelper: 'ເປີດໃຊ້ການບີບອັດ brotli, ປົກກະຕິນ້ອຍກວ່າ gzip', + brotliCompLevelHelper: 'ອັດຕາການບີບອັດ brotli, 0 ຫາ 11', + brotliManagedExternallyHelper: + 'brotli ຖືກຕັ້ງຄ່າດ້ວຍມືໃນ nginx.conf; ແຜງຄວບຄຸມຈະສະແດງຄ່າທີ່ກຳລັງໃຊ້ຢູ່ ແລະ ຈະບໍ່ຂຽບທັບ.', + brotliManagedUnavailableHelper: + 'ແຜງຄວບຄຸມບໍ່ສາມາດເພີ່ມການຕັ້ງຄ່າ brotli ທີ່ຈັດການໄປຍັງ nginx.conf ໂດຍອັດຕະໂນມັດໄດ້; ຄ່າຂ້າງລຸ່ມນີ້ຈະບໍ່ມີຜົນ.', + brotliMinLengthHelper: 'ຂະໜາດຕອບສະໜອງຂັ້ນຕ່ຳທີ່ຈະບີບອັດດ້ວຍ brotli', + brotliSaveFailed: 'ບັນທຶກການຕັ້ງຄ່າ brotli ບໍ່ສຳເລັດ; ການຕັ້ງຄ່າ gzip ຂ້າງເທິງໄດ້ຖືກນຳໃຊ້ແລ້ວ', connections: 'ການເຊື່ອມຕໍ່ທີ່ກຳລັງເຮັດວຽກ', accepts: 'ຍອມຮັບແລ້ວ', handled: 'ຈັດການແລ້ວ', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 0ab85801c796..97bfdd1ae314 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -3966,6 +3966,14 @@ const message = { gzipMinLengthHelper: 'Saiz minimum fail untuk pemampatan', gzipCompLevelHelper: 'Kadar mampatan', gzipHelper: 'Aktifkan pemampatan untuk penghantaran', + brotliHelper: 'Aktifkan pemampatan brotli, biasanya lebih kecil daripada gzip', + brotliCompLevelHelper: 'Kadar pemampatan brotli, 0 hingga 11', + brotliManagedExternallyHelper: + 'Brotli dikonfigurasikan secara manual dalam nginx.conf; panel memaparkan nilai yang berkuat kuasa dan tidak akan menimpanya.', + brotliManagedUnavailableHelper: + 'Panel tidak dapat menambahkan konfigurasi brotli terurus ke nginx.conf secara automatik; nilai di bawah tidak akan berkuat kuasa.', + brotliMinLengthHelper: 'Saiz respons minimum untuk dimampatkan dengan brotli', + brotliSaveFailed: 'Tetapan brotli gagal disimpan; tetapan gzip di atas telah digunakan', connections: 'Sambungan aktif', accepts: 'Diterima', handled: 'Diuruskan', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 33ba342f4709..eb854537874c 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -3986,6 +3986,14 @@ const message = { gzipMinLengthHelper: 'Tamanho mínimo para compressão', gzipCompLevelHelper: 'Nível de compressão', gzipHelper: 'Ativar compressão na transmissão', + brotliHelper: 'Ativar compressão brotli, geralmente menor que gzip', + brotliCompLevelHelper: 'Taxa de compressão brotli, de 0 a 11', + brotliManagedExternallyHelper: + 'O brotli está configurado manualmente no nginx.conf; o painel mostra os valores em vigor e não os sobrescreve.', + brotliManagedUnavailableHelper: + 'O painel não conseguiu adicionar automaticamente a configuração brotli gerenciada ao nginx.conf; os valores abaixo não terão efeito.', + brotliMinLengthHelper: 'Tamanho mínimo da resposta para compressão brotli', + brotliSaveFailed: 'Falha ao salvar as configurações brotli; as configurações gzip acima foram aplicadas', connections: 'Conexões ativas', accepts: 'Accepts', handled: 'Handled', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 48e2c45b4d7c..79a3e1d403fd 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -3954,6 +3954,14 @@ const message = { gzipMinLengthHelper: 'Минимальный размер сжатого файла', gzipCompLevelHelper: 'Степень сжатия', gzipHelper: 'Включить сжатие для передачи', + brotliHelper: 'Включить сжатие brotli, обычно меньше, чем gzip', + brotliCompLevelHelper: 'Степень сжатия brotli, от 0 до 11', + brotliManagedExternallyHelper: + 'Brotli настроен вручную в nginx.conf; панель показывает действующие значения и не перезаписывает их.', + brotliManagedUnavailableHelper: + 'Панель не смогла автоматически добавить управляемую конфигурацию brotli в nginx.conf; значения ниже не вступят в силу.', + brotliMinLengthHelper: 'Минимальный размер ответа для сжатия brotli', + brotliSaveFailed: 'Не удалось сохранить настройки brotli; настройки gzip выше были применены', connections: 'Активные соединения', accepts: 'Принято', handled: 'Обработано', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index bbc41c439d7f..5e072d833f4e 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -3969,6 +3969,14 @@ const message = { gzipMinLengthHelper: 'Minimum Sıkıştırılmış Dosya', gzipCompLevelHelper: 'Sıkıştırma Oranı', gzipHelper: 'İletim için sıkıştırmayı etkinleştir', + brotliHelper: 'brotli sıkıştırmayı etkinleştir, genellikle gzip ile karşılaştırıldığında daha küçüktür', + brotliCompLevelHelper: 'Brotli sıkıştırma oranı, 0 ile 11 arası', + brotliManagedExternallyHelper: + 'Brotli, nginx.conf dosyasında elle yapılandırıldı; panel yalnızca geçerli değerleri gösterir ve üzerine yazmaz.', + brotliManagedUnavailableHelper: + 'Panel, yönetilen brotli yapılandırmasını nginx.conf dosyasına otomatik olarak ekleyemedi; aşağıdaki değerler etkin olmayacak.', + brotliMinLengthHelper: 'brotli ile sıkıştırılacak minimum yanıt boyutu', + brotliSaveFailed: 'Brotli ayarları kaydedilemedi; yukarıdaki gzip ayarları uygulandı', connections: 'Aktif bağlantılar', accepts: 'Kabul edilenler', handled: 'İşlenenler', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 3e571d962e03..f24e9221593d 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -3648,6 +3648,12 @@ const message = { gzipMinLengthHelper: '最小壓縮檔案', gzipCompLevelHelper: '壓縮率', gzipHelper: '是否開啟壓縮傳輸', + brotliHelper: '開啟 brotli 壓縮,通常比 gzip 體積更小', + brotliCompLevelHelper: 'brotli 壓縮率,取值 0 到 11', + brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手動設定,面板僅顯示目前生效值,不會覆寫。', + brotliManagedUnavailableHelper: '面板無法自動向 nginx.conf 寫入託管的 brotli 設定,以下設定不會生效。', + brotliMinLengthHelper: 'brotli 壓縮的最小回應大小', + brotliSaveFailed: 'brotli 設定儲存失敗,上方的 gzip 設定已套用', connections: '活動連接(Active connections)', accepts: '總連接次數(accepts)', handled: '總握手次數(handled)', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 7ce725430074..750a34bfe317 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3693,6 +3693,12 @@ const message = { gzipMinLengthHelper: '最小压缩文件', gzipCompLevelHelper: '压缩率', gzipHelper: '是否开启压缩传输', + brotliHelper: '开启 brotli 压缩,通常比 gzip 体积更小', + brotliCompLevelHelper: 'brotli 压缩率,取值 0 到 11', + brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手动配置,面板仅显示当前生效值,不会覆盖。', + brotliManagedUnavailableHelper: '面板无法自动向 nginx.conf 写入托管的 brotli 配置,以下设置不会生效。', + brotliMinLengthHelper: 'brotli 压缩的最小响应大小', + brotliSaveFailed: 'brotli 设置保存失败,上方的 gzip 设置已应用', connections: '活动连接(Active connections)', accepts: '总连接次数(accepts)', handled: '总握手次数(handled)', diff --git a/frontend/src/views/website/website/nginx/performance/index.vue b/frontend/src/views/website/website/nginx/performance/index.vue index 62719a6311dc..2069fc6905d0 100644 --- a/frontend/src/views/website/website/nginx/performance/index.vue +++ b/frontend/src/views/website/website/nginx/performance/index.vue @@ -13,13 +13,13 @@ - + {{ $t('nginx.clientHeaderBufferSizeHelper') }} - + {{ $t('nginx.clientMaxBodySizeHelper') }} @@ -38,7 +38,7 @@ - + {{ $t('nginx.gzipMinLengthHelper') }} @@ -48,6 +48,39 @@ + + + + {{ $t('nginx.brotliManagedExternallyHelper') }} + + + + + {{ $t('nginx.brotliManagedUnavailableHelper') }} + + + + + + + + + {{ $t('nginx.brotliHelper') }} + + + + + + {{ $t('nginx.brotliMinLengthHelper') }} + + + + + + {{ $t('nginx.brotliCompLevelHelper') }} + + + {{ $t('commons.button.save') }} @@ -58,10 +91,10 @@