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/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..c992692b803d 100644 --- a/agent/app/service/nginx.go +++ b/agent/app/service/nginx.go @@ -63,6 +63,9 @@ func (n NginxService) GetNginxConfig() (*response.NginxFile, error) { } func (n NginxService) GetConfigByScope(req request.NginxScopeReq) ([]response.NginxParam, 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..16a798072569 --- /dev/null +++ b/agent/app/service/nginx_gzip_upgrade.go @@ -0,0 +1,165 @@ +package service + +import ( + "os" + "path" + "regexp" + "sort" + "strings" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "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 = os.WriteFile(configPath, []byte(updated), constant.FilePerm); 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 + indent := " " + + for _, line := range lines { + match := nginxGzipDirectiveRe.FindStringSubmatch(line) + if match == nil { + result = append(result, line) + continue + } + name := match[1] + if leading := line[:len(line)-len(strings.TrimLeft(line, " \t"))]; leading != "" { + indent = leading + } + if _, obsolete := obsoleteNginxGzipDirectives[name]; obsolete { + continue + } + seen[name] = struct{}{} + if replacement, ok := correctedNginxGzipDirectives[name]; ok { + result = append(result, indent+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, indent+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..03942e68ab47 --- /dev/null +++ b/agent/app/service/nginx_gzip_upgrade_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "strings" + "testing" +) + +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") + } +} diff --git a/agent/app/service/nginx_http_config.go b/agent/app/service/nginx_http_config.go new file mode 100644 index 000000000000..6628a45123e7 --- /dev/null +++ b/agent/app/service/nginx_http_config.go @@ -0,0 +1,150 @@ +package service + +import ( + "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" +) + +// 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, " ") + ";" +} + +// nginxHTTPConfigSupported reports whether the installed OpenResty exposes the +// managed http.d directory. Installations created before http.d was introduced +// have no such directory and no include for it, so writing files there would +// silently have no effect. +func nginxHTTPConfigSupported(install model.AppInstall) bool { + info, err := os.Stat(nginxHTTPConfigDir(install)) + return err == nil && info.IsDir() +} + +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_module.go b/agent/app/service/nginx_module.go index d299cb6b0a4b..705f61cc23f2 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,37 @@ 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. + httpConfigDir := nginxHTTPConfigDir(install) + httpSupported := nginxHTTPConfigSupported(install) + var httpSnapshot nginxModuleConfigSnapshot + if httpSupported { + if httpSnapshot, err = snapshotManagedNginxHTTPConfigs(httpConfigDir); err != nil { + return err + } + } + restore := func() { _ = applyManagedNginxModuleConfigs(configDir, snapshot) + if httpSupported { + _ = applyManagedNginxHTTPConfigs(httpConfigDir, httpSnapshot) + } + } + + if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil { + restore() return err } + if httpSupported { + desiredHTTP := desiredNginxModuleRuntimeConfigs(install, modules, target) + if err = applyManagedNginxHTTPConfigs(httpConfigDir, desiredHTTP); err != nil { + restore() + return err + } + } if !reload { return nil } @@ -666,11 +739,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 +795,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 +813,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 +877,21 @@ 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 install predates dynamic modules. Compile the same modules into + // the image instead of refusing the build, which is how these versions + // have always produced modules. + if !nginxModuleStaticSupported(install) { + return buserr.New("ErrModuleBuildUnsupported") + } + if modules, err = convertNginxModulesToStatic(modules, reqModules); err != nil { + return err } + staticBuild = true } if staticBuild { return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask) @@ -886,7 +966,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 } @@ -1266,6 +1356,43 @@ func normalizeDynamicModuleParams(params string) (string, error) { return params, nil } +// normalizeStaticModuleParams is the inverse of normalizeDynamicModuleParams: +// a module compiled into the image uses --add-module and the plain form of +// nginx's built-in switches. +func normalizeStaticModuleParams(params string) string { + params = strings.TrimSpace(params) + params = strings.ReplaceAll(params, "--add-dynamic-module=", "--add-module=") + return strings.ReplaceAll(params, "=dynamic", "") +} + +// convertNginxModulesToStatic retargets the modules a build was asked to +// produce so they are compiled into the image. It is used when the install +// has no dynamic builder, where this is the only way to produce a module. +// +// The change is confined to the returned slice: it drives one build and is +// never persisted, so the catalog's declared mode stays authoritative and the +// modules go back to dynamic once the install gains a builder. +func convertNginxModulesToStatic(modules []dto.NginxModule, selected []string) ([]dto.NginxModule, error) { + selectedNames := make(map[string]struct{}, len(selected)) + for _, name := range selected { + selectedNames[name] = struct{}{} + } + converted := cloneNginxModules(modules) + for i := range converted { + if !nginxModuleNeedsDynamicBuild(converted[i], selectedNames) { + continue + } + params := normalizeStaticModuleParams(converted[i].Params) + if params == "" { + return nil, fmt.Errorf("OpenResty module %s has no configure options to compile into the image", converted[i].Name) + } + converted[i].BuildMode = nginxModuleBuildStatic + converted[i].Params = params + converted[i].Enable = true + } + return converted, nil +} + func parseDynamicModuleParams(params string) ([]string, error) { // shellwords silently stops at unquoted shell metacharacters instead of // reporting them, so reject them on the raw input before parsing. diff --git a/agent/app/service/nginx_module_runtime.go b/agent/app/service/nginx_module_runtime.go new file mode 100644 index 000000000000..0353550304b0 --- /dev/null +++ b/agent/app/service/nginx_module_runtime.go @@ -0,0 +1,203 @@ +package service + +import ( + "path" + + "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" +) + +// 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. +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) + directives, ok := nginxModuleRuntimeDefaults[module.Name] + if !ok || !module.Enable { + continue + } + if !nginxModuleRuntimeReady(module, target) { + continue + } + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(module.Name), module.Name) + current := readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName)) + desired[fileName] = renderNginxHTTPConfig(mergeNginxRuntimeDirectives(directives, current)) + } + return desired +} + +// 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. +// +// Brotli is 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. +func getNginxBrotliParams() ([]response.NginxParam, error) { + install, err := getAppInstallByKey(constant.AppOpenresty) + if err != nil { + return nil, err + } + fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName) + current := readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName)) + res := make([]response.NginxParam, 0, len(dto.BrotliKeys)) + for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) { + res = append(res, response.NginxParam{Name: directive.Name, Params: directive.Params}) + } + return res, 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 + } + if !nginxHTTPConfigSupported(install) { + return buserr.New("ErrBrotliUnsupported") + } + 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 + } + for i := range modules { + if modules[i].Name != nginxBrotliModuleName { + continue + } + if !modules[i].Enable { + return buserr.New("ErrBrotliDisabled") + } + 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 + } + return nil + } + return buserr.New("ErrBrotliDisabled") +} + +// 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. +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_static_test.go b/agent/app/service/nginx_module_static_test.go new file mode 100644 index 000000000000..18372f5b5c83 --- /dev/null +++ b/agent/app/service/nginx_module_static_test.go @@ -0,0 +1,205 @@ +package service + +import ( + "strings" + "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) + } +} + +func TestNormalizeStaticModuleParams(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "dynamic add-module is reversed", + in: "--add-dynamic-module=/tmp/ngx_http_geoip2_module", + want: "--add-module=/tmp/ngx_http_geoip2_module", + }, + { + name: "already static params are left alone", + in: "--add-module=/usr/local/openresty/modules/ngx_brotli", + want: "--add-module=/usr/local/openresty/modules/ngx_brotli", + }, + { + name: "built-in switches drop the dynamic suffix", + in: "--with-http_image_filter_module=dynamic", + want: "--with-http_image_filter_module", + }, + { + name: "mixed options are handled together", + in: "--with-http_dav_module --add-dynamic-module=/tmp/nginx-dav-ext-module", + want: "--with-http_dav_module --add-module=/tmp/nginx-dav-ext-module", + }, + { + name: "surrounding whitespace is trimmed", + in: " --add-dynamic-module=/tmp/x ", + want: "--add-module=/tmp/x", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeStaticModuleParams(tc.in); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +// The conversion must round-trip the params the dynamic path produces, +// otherwise an install without a builder would compile the wrong thing. +func TestStaticParamsRoundTripFromDynamic(t *testing.T) { + // These are the catalog params shipped with the OpenResty app. + for _, original := range []string{ + "--add-module=/usr/local/openresty/modules/ngx_brotli", + "--add-module=/tmp/nginx-rtmp-module", + "--with-http_dav_module --add-module=/tmp/nginx-dav-ext-module", + "--add-module=/tmp/ngx_http_geoip2_module", + "--add-module=/tmp/ngx_http_substitutions_filter_module", + } { + dynamic, err := normalizeDynamicModuleParams(original) + if err != nil { + t.Fatalf("%s: %v", original, err) + } + if got := normalizeStaticModuleParams(dynamic); got != original { + t.Errorf("round trip changed the options:\n from %q\n via %q\n to %q", original, dynamic, got) + } + } +} + +func TestConvertNginxModulesToStatic(t *testing.T) { + modules := []dto.NginxModule{ + {Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic, + Params: "--add-dynamic-module=/usr/local/openresty/modules/ngx_brotli"}, + {Name: "rtmp", Enable: false, BuildMode: nginxModuleBuildDynamic, + Params: "--add-dynamic-module=/tmp/nginx-rtmp-module"}, + } + converted, err := convertNginxModulesToStatic(modules, nil) + if err != nil { + t.Fatal(err) + } + if converted[0].BuildMode != nginxModuleBuildStatic { + t.Error("an enabled module should be retargeted to static") + } + if !strings.Contains(converted[0].Params, "--add-module=") || + strings.Contains(converted[0].Params, "--add-dynamic-module=") { + t.Errorf("params were not converted: %q", converted[0].Params) + } + if converted[1].BuildMode != nginxModuleBuildDynamic { + t.Error("a disabled module must be left alone") + } + + // The caller's slice must survive untouched: the conversion drives a single + // build and is never written back to module.json. + if modules[0].BuildMode != nginxModuleBuildDynamic { + t.Error("the input slice was mutated") + } + if modules[0].Params != "--add-dynamic-module=/usr/local/openresty/modules/ngx_brotli" { + t.Errorf("input params were mutated: %q", modules[0].Params) + } +} + +func TestConvertNginxModulesToStaticHonoursSelection(t *testing.T) { + modules := []dto.NginxModule{ + {Name: "ngx_brotli", Enable: true, BuildMode: nginxModuleBuildDynamic, + Params: "--add-dynamic-module=/a"}, + {Name: "rtmp", Enable: true, BuildMode: nginxModuleBuildDynamic, + Params: "--add-dynamic-module=/b"}, + } + converted, err := convertNginxModulesToStatic(modules, []string{"rtmp"}) + if err != nil { + t.Fatal(err) + } + if converted[0].BuildMode != nginxModuleBuildDynamic { + t.Error("an unselected module must not be converted") + } + if converted[1].BuildMode != nginxModuleBuildStatic { + t.Error("the selected module should be converted") + } +} + +func TestConvertNginxModulesToStaticRejectsEmptyParams(t *testing.T) { + modules := []dto.NginxModule{ + {Name: "broken", Enable: true, BuildMode: nginxModuleBuildDynamic, Params: " "}, + } + if _, err := convertNginxModulesToStatic(modules, nil); err == nil { + t.Fatal("a module with no configure options cannot be compiled in") + } +} 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/cmd/server/nginx_conf/nginx_conf.go b/agent/cmd/server/nginx_conf/nginx_conf.go index cf2176146a6d..e540065fddf5 100644 --- a/agent/cmd/server/nginx_conf/nginx_conf.go +++ b/agent/cmd/server/nginx_conf/nginx_conf.go @@ -44,6 +44,9 @@ var Upstream []byte //go:embed sse.conf var SSE []byte +//go:embed gzip.conf +var Gzip []byte + //go:embed *.json *.conf var websitesFiles embed.FS diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 9af2b721a0df..6f3e4baed004 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 installed OpenResty version does not support managed brotli settings' +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..bed77edd84c3 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: 'La versión instalada de OpenResty no admite configuraciones brotli gestionadas' +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..f45f4c716f42 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: 'نسخه نصب‌شده OpenResty از تنظیمات مدیریت‌شده brotli پشتیبانی نمی‌کند' +ErrModuleBuildUnsupported: 'این نسخه OpenResty نمی‌تواند ماژول بسازد، ابتدا آن را ارتقا دهید' ErrImageNotExist: 'تصویر محیط اجرا یافت نشد: {{ .name }}' ErrProxyIsUsed: 'تعادل بار توسط پراکسی معکوس استفاده می‌شود' ErrSSLValid: 'فایل گواهی نامعتبر است' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index bb4d1a1a0389..b198968d787e 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: 'インストールされている OpenResty のバージョンは管理された brotli 設定をサポートしていません' +ErrModuleBuildUnsupported: 'この OpenResty バージョンではモジュールをビルドできません。先にアップグレードしてください' ErrImageNotExist: 'オペレーティング環境 {{ .name }} イメージが存在しません。オペレーティング環境を再編集してください' ErrProxyIsUsed: 'ロードバランシングはリバースプロキシによって使用されているため、削除できません' ErrSSLValid: '証明書ファイルが異常です、証明書の状態を確認してください!' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index f47378a22c45..6622ae82398c 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: '설치된 OpenResty 버전은 관리형 brotli 설정을 지원하지 않습니다' +ErrModuleBuildUnsupported: '현재 OpenResty 버전은 모듈을 빌드할 수 없습니다. 먼저 업그레이드하세요' ErrImageNotExist: '운영 환경 {{ .name }} 이미지가 존재하지 않습니다. 운영 환경을 다시 편집하세요.' ErrProxyIsUsed: '로드 밸런싱이 역방향 프록시에 의해 사용되었으므로 삭제할 수 없습니다' ErrSSLValid: '인증서 파일에 문제가 있습니다. 인증서 상태를 확인하세요' diff --git a/agent/i18n/lang/lo.yaml b/agent/i18n/lang/lo.yaml index 98394f4784cd..bdd7b43bd42f 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: 'ລຸ້ນ OpenResty ທີ່ຕິດຕັ້ງບໍ່ຮອງຮັບການຕັ້ງຄ່າ brotli ແບບຈັດການ' +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..a7f0b640edd6 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: 'Versi OpenResty yang dipasang tidak menyokong tetapan brotli terurus' +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..1ed210831f43 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: 'A versão instalada do OpenResty não suporta configurações brotli gerenciadas' +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..2dafe675100c 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: 'Установленная версия OpenResty не поддерживает управляемые настройки brotli' +ErrModuleBuildUnsupported: 'Эта версия OpenResty не может собирать модули, сначала обновите её' ErrImageNotExist: 'Образ операционной среды {{ .name }} не существует, пожалуйста, отредактируйте операционную среду заново' ErrProxyIsUsed: 'Балансировка нагрузки используется обратным прокси, невозможно удалить' ErrSSLValid: 'Файл сертификата аномален, проверьте статус сертификата' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index c71d97454e94..1d041bda47dd 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: 'Kurulu OpenResty sürümü yönetilen brotli ayarlarını desteklemiyor' +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..d82340ff6b82 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: '目前 OpenResty 版本不支援託管 brotli 設定' +ErrModuleBuildUnsupported: '目前 OpenResty 版本無法建置模組,請先升級' ErrImageNotExist: '執行環境{{ .name }} 映像不存在,請重新編輯執行環境' ErrProxyIsUsed: '負載均衡已被反向代理使用,無法刪除' ErrSSLValid: '憑證檔案異常,請檢查憑證狀態!' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 77ec11697cac..4b5f6fb15a3f 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: "当前 OpenResty 版本不支持托管 brotli 配置" +ErrModuleBuildUnsupported: "当前 OpenResty 版本无法构建模块,请先升级" ErrImageNotExist: "运行环境 {{ .name }} 镜像不存在,请重新编辑运行环境" ErrProxyIsUsed: "负载均衡已被反向代理使用,无法删除" ErrSSLValid: '证书文件异常,请检查证书状态!' diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 49baa422f7c9..633d1e219d2d 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -3904,6 +3904,8 @@ 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', 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..acf994011189 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -3951,6 +3951,8 @@ 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', 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..5b488f6374bb 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -3871,6 +3871,8 @@ const message = { gzipMinLengthHelper: 'حداقل فایل فشرده', gzipCompLevelHelper: 'نرخ فشرده‌سازی', gzipHelper: 'فعال‌سازی فشرده‌سازی برای انتقال', + brotliHelper: 'فعال‌سازی فشرده‌سازی brotli، معمولاً کوچک‌تر از gzip', + brotliCompLevelHelper: 'نرخ فشرده‌سازی brotli، از 0 تا 11', connections: 'اتصال‌های فعال', accepts: 'پذیرش‌ها', handled: 'مدیریت شده', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index e7cacbc98fa3..c02edc3072ab 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -3894,6 +3894,8 @@ const message = { gzipMinLengthHelper: '最小圧縮ファイル', gzipCompLevelHelper: '圧縮率', gzipHelper: '伝送の圧縮を有効にします', + brotliHelper: 'brotli 圧縮を有効にします。通常 gzip より小さくなります', + brotliCompLevelHelper: 'brotli 圧縮率、0 から 11', connections: 'アクティブな接続', accepts: '受け入れます', handled: '処理', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 716aa7bb72dd..07d3bf83f414 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -3823,6 +3823,8 @@ const message = { gzipMinLengthHelper: '최소 압축 파일 크기', gzipCompLevelHelper: '압축률', gzipHelper: '전송을 위한 압축 활성화', + brotliHelper: 'brotli 압축 활성화, 일반적으로 gzip보다 작습니다', + brotliCompLevelHelper: 'brotli 압축률, 0에서 11까지', connections: '활성 연결', accepts: '수락', handled: '처리됨', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index ab9080d57ec8..5fee100ccb87 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -3798,6 +3798,8 @@ const message = { gzipMinLengthHelper: 'ຂະໜາດໄຟລ໌ຕ່ຳສຸດທີ່ຈະບີບອັດ', gzipCompLevelHelper: 'ອັດຕາການບີບອັດ', gzipHelper: 'ເປີດໃຊ້ການບີບອັດໃນການຮັບສົ່ງຂໍ້ມູນ', + brotliHelper: 'ເປີດໃຊ້ການບີບອັດ brotli, ປົກກະຕິນ້ອຍກວ່າ gzip', + brotliCompLevelHelper: 'ອັດຕາການບີບອັດ brotli, 0 ຫາ 11', connections: 'ການເຊື່ອມຕໍ່ທີ່ກຳລັງເຮັດວຽກ', accepts: 'ຍອມຮັບແລ້ວ', handled: 'ຈັດການແລ້ວ', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 0ab85801c796..827cd6aa0138 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -3966,6 +3966,8 @@ 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', 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..bbac49543fb9 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -3986,6 +3986,8 @@ 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', 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..3fde397a35e0 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -3954,6 +3954,8 @@ const message = { gzipMinLengthHelper: 'Минимальный размер сжатого файла', gzipCompLevelHelper: 'Степень сжатия', gzipHelper: 'Включить сжатие для передачи', + brotliHelper: 'Включить сжатие brotli, обычно меньше, чем gzip', + brotliCompLevelHelper: 'Степень сжатия brotli, от 0 до 11', connections: 'Активные соединения', accepts: 'Принято', handled: 'Обработано', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index bbc41c439d7f..796ec75de3c4 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -3969,6 +3969,8 @@ 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ı', 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..7848724d3ece 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -3648,6 +3648,8 @@ const message = { gzipMinLengthHelper: '最小壓縮檔案', gzipCompLevelHelper: '壓縮率', gzipHelper: '是否開啟壓縮傳輸', + brotliHelper: '開啟 brotli 壓縮,通常比 gzip 體積更小', + brotliCompLevelHelper: 'brotli 壓縮率,取值 0 到 11', 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..4b7fd24485c6 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3693,6 +3693,8 @@ const message = { gzipMinLengthHelper: '最小压缩文件', gzipCompLevelHelper: '压缩率', gzipHelper: '是否开启压缩传输', + brotliHelper: '开启 brotli 压缩,通常比 gzip 体积更小', + brotliCompLevelHelper: 'brotli 压缩率,取值 0 到 11', 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..46ebeb9667f0 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,29 @@ + + + + + + + + {{ $t('nginx.brotliHelper') }} + + + + + + {{ $t('nginx.gzipMinLengthHelper') }} + + + + + + {{ $t('nginx.brotliCompLevelHelper') }} + + + {{ $t('commons.button.save') }} @@ -58,7 +81,7 @@