mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-20 08:03:55 +08:00
feat(openresty): activate brotli when the module is enabled, and fix gzip defaults (#13639)
* feat(openresty): manage http-context directives via conf/http.d Add a managed-file mechanism for http-context nginx directives, mirroring the existing one for conf/modules-enabled. A separate directory is required because load_module is a main-context directive, so modules-enabled is included at the top level of nginx.conf and cannot host http-context directives. Files carry a 1panel-http- prefix; anything else in the directory is left untouched. Writes are atomic via a temporary file plus rename, and the directory is snapshotted so a failed nginx -t can be rolled back. The mechanism is inert when conf/http.d does not exist, which is the case for OpenResty installations predating the directory. * fix(openresty): correct gzip defaults and add missing compressible types Bring the embedded gzip template in line with how sites are actually served. It was previously dead code: nothing referenced gzip.conf, so the values never reached an installation. It is now embedded and used by the migration that follows. gzip_types was missing application/json, so JSON API responses were served uncompressed. Also add ld+json, text/xml, xhtml+xml, rss+xml, atom+xml, wasm, svg+xml and ttf/otf. Already compressed formats (images, woff2, archives) stay out on purpose. gzip_comp_level 6 -> 5, at the cost/ratio knee for gzip. gzip_proxied any, so that proxied responses are compressed regardless of their Cache-Control semantics. gzip_static is intentionally not enabled: nginx does not verify that a .gz file is newer than its source, so a stale artifact would be served indefinitely with no error. * feat(openresty): activate brotli directives when the module is enabled Enabling ngx_brotli only emitted load_module, leaving the module loaded but inert: no response was ever brotli-encoded until the user added `brotli on` and `brotli_types` to nginx.conf by hand. The module is prebuilt into the OpenResty image and listed in the catalog, so the only missing step was the runtime configuration. Enabling the module now also writes its http-context directives to conf/http.d, and disabling or deleting it removes them. Removal matters: leaving `brotli on` behind after the .so is unloaded makes nginx fail to start on an unknown directive. Both directory sets are written before nginx -t runs, so nginx only ever observes a consistent state, and a failed check rolls back load_module files and runtime directives together. Runtime defaults are declared per module in a table, so other modules needing http-context configuration can be added without touching the reconcile logic. brotli_types matches gzip_types so both encoders cover the same content. brotli_comp_level is 5 rather than the nginx default of 6: level 5 reaches roughly gzip level 9 ratio at a fraction of the cost, while 6 is tuned for static assets and is too expensive for dynamic responses. brotli_static is deliberately omitted, for the same reason gzip_static is: nginx does not verify that a precompressed artifact is newer than its source, so a stale file would be served indefinitely with no error. Installations without conf/http.d keep the previous behaviour instead of failing. * feat(openresty): refresh stock gzip defaults on upgrade Upgrades deliberately preserve the user's nginx.conf, so corrected gzip defaults shipped with a new OpenResty version never reach existing installations. Rewrite the values in place during upgrade, but only when the block is provably untouched. The rewrite requires every gzip directive to match the factory values byte for byte, with none missing, none added and none duplicated. Any deviation means the user tuned compression, and their configuration is left alone. gzip stays in the http block of nginx.conf rather than moving to an included file: nginx rejects a duplicate gzip directive across contexts, and the compression settings page reads and writes these same keys in nginx.conf, so a relocated block would be reintroduced on the next save and break nginx -t. The config parser is not used either. 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. The rewrite is idempotent, and a failed nginx -t restores the previous file. A failure is logged as a warning instead of failing the upgrade. * fix(website): preserve size units in nginx performance settings The form stripped the unit suffix when reading a directive and then always appended a fixed one when saving, so the unit was silently reinterpreted. A config carrying `gzip_min_length 512;`, meaning 512 bytes, was read as 512 and written back as `512k`, inflating the threshold by 1024 and effectively disabling compression for every response under 512 KB. The same applied to client_header_buffer_size and client_max_body_size, where the value grew by a factor of 1024 in the opposite, riskier direction. Remember the unit that was read and write it back unchanged, defaulting to the previous suffix only when the directive carries no unit information. The input suffix now shows the unit actually in use instead of a hardcoded label. Also fix the value parsing itself: `Number(value.match(/\d+/g))` coerces a multi-number match to NaN, so a directive such as `gzip_buffers 4 16k` would blank the field. Take the first captured number instead. * feat(website): expose brotli settings in the compression page Brotli could be enabled as a module but never configured from the panel, so its behaviour was invisible and unchangeable without editing nginx.conf by hand. The section appears only once the module is enabled and built, since the directives are rejected by nginx while the module is not loaded. Values are read from and written to the panel-managed http.d file rather than nginx.conf, so they are removed together with the module. brotli_types stays out of the form on purpose: it is kept aligned with gzip_types so both encoders cover the same content, and exposing it would invite the two lists to drift apart. Saving reuses the existing scope endpoint with a dedicated brotli scope, which keeps the managed file as the single source of truth instead of duplicating the values into nginx.conf. * fix(openresty): stop a stale build option from forcing a full rebuild Manual builds and upgrades disagreed on when a full OpenResty image rebuild is required. `executeNginxModuleBuild` used `staticNginxBuildRequired`, which also treated a non-empty `RESTY_CONFIG_OPTIONS_MORE` in .env as a reason to rebuild, while `buildNginx` looked only at the module list. The env value is derived state, not an input: `configureStaticNginxModules` rewrites it from the current module list, and every build path calls that function before building. With no static module enabled it writes an empty string, so the rebuild the latch triggered ran with an empty option list and could only reproduce the image it started from — up to 120 minutes of build time to arrive back where it began. Decide on the module list alone, which is what the upgrade path already did. An install that genuinely has an enabled static module is unaffected: both predicates already agreed in that case. Leftover values are still cleared, by `configureStaticNginxModules` on the next build or upgrade. * feat(openresty): build modules on versions without a dynamic builder Module state written before build modes existed carries no buildMode. validateNginxModuleBuildMode rejects the empty value, which fails loadNginxModules and with it every module operation and the upgrade itself — the whole module subsystem, not just the static feature. Infer the missing value from what the install can actually do instead: dynamic when the builder and catalog are present, static when the compose file still has a build section and build/Dockerfile to recompile the image. Builds follow the same principle. Asking a pre-dynamic install to build a module used to return "the installed OpenResty version does not support dynamic module builds", which is a dead end: these versions produce modules by compiling them into the image, and they still can. Such a build is now retargeted to the static path, with --add-dynamic-module rewritten back to --add-module and =dynamic switches reduced to their plain form. The error is kept only for installs that reference a prebuilt image and genuinely cannot compile anything, and it now says so and points at the upgrade. The retarget applies to a copy that drives one build and is never persisted, so the catalog stays authoritative and modules return to dynamic once the install gains a builder. Verified end to end against 1.27.1.2-5-1-focal, which ships no Dockerfile.modules and no module.catalog.json: ngx_brotli compiles into the image, nginx -t accepts the brotli directives with no load_module present, and the server responds with Content-Encoding: br. * feat(openresty): respect a hand-written brotli configuration A user who enabled brotli before the panel managed it did so by editing their configuration by hand. Emitting a managed file alongside it defined every directive twice and nginx refused to start, so these users — the very ones this feature is for — broke on upgrade. Detection now scans every file nginx loads brotli from: nginx.conf and the conf.d and default includes. Any active brotli* directive counts, so a lone tuning directive is enough to treat the module as user-managed, and a commented-out line never triggers it. When the user owns the configuration, the panel stays out of the way: - No managed http.d file is written, so the user's definition stays the only one and their values are never overridden. - brotli_types diverging from gzip_types is left exactly as written; the panel does not widen them. - The settings page reports their real values and shows a notice that brotli is managed manually, rather than presenting defaults that do not match the running configuration. - Saving edits their own lines in place, keeping indentation and comments, instead of writing a second copy. The flag is localised in all 12 languages. Detection re-runs on every reconcile, so once the user deletes their hand-written config the panel takes over again automatically. * feat(openresty): wire conf/http.d from the agent instead of upgrade scripts Following review feedback: setup scripts no longer create conf/http.d or inject its include into existing installations' nginx.conf. The agent owns the directory, the include, the runtime directives and the rollback, and only touches nginx.conf when a module that needs http-context configuration is actually enabled. Insertion is a line-level edit, never the config parser: the include lands before the conf.d include, or at the top of the http block when that anchor is absent, keeping the surrounding indentation and leaving the rest of the file byte-identical. A config without a locatable http block degrades to the previous behaviour — module loads, runtime directives skipped, warning logged — instead of failing the operation. Detection re-runs on every reconcile, so an install recovers on its own once nginx.conf can be edited again. Rollback now covers three artefacts: modules-enabled, http.d, and the inserted line in nginx.conf. The include is kept when the last module is disabled. Pointing at an empty directory is harmless, and removing it would mean another edit of the user's main config with its own failure surface. When the include is missing and cannot be inserted, the brotli settings report ManagedUnavailable and the settings page warns that the values shown will not take effect, instead of presenting inert settings as live. * fix(openresty): tighten brotli ownership handling and build guards The settings page could not save brotli values for users who wrote their own directives after the panel had started managing the module: the stale managed file was still on disk, so every save ended in a duplicate directive error. That file is now removed before the in-place edit, and a failed nginx -t rolls back both sides. User-managed detection now also covers conf/default, which is included at http scope like conf.d, and the http.d include check no longer depends on the exact container path literal, so an include written in a slightly different form is recognised instead of duplicated. The dynamic-to-static build fallback is dropped. The catalog and the dynamic builder ship together, and installs without the catalog fail to load their module state earlier anyway, so the branch could never run; what remains is an error that says the version cannot build modules and to upgrade first. The embedded gzip template is no longer wired to an unused variable, and a test keeps it in sync with the defaults the upgrade writes. Smaller fixes in the same area: a custom module named ngx_brotli no longer inherits the built-in runtime defaults; nginx.conf edits go through temp file renames and inserted lines follow the file's own line endings; the gzip rewrite keeps each line's own indentation; the settings page resets its unit cache on load, warns when the brotli half of a save fails after gzip already applied, and no longer coerces unrendered keys to zero. * fix(openresty): prove brotli reached the running server, not just disk nginx -t and a successful reload both pass even when the managed directory never reaches the container: the include is a glob, so a missing bind mount or an unrecognised include variant silently loads nothing. The brotli save now reads the effective configuration back with nginx -T and rolls the write back with an actionable error when the directives are not there, instead of reporting success for settings nobody will ever see. The include match also accepts the quoted form nginx permits, so a hand-written or legacy variant no longer invites a second include of the same directory. Values written into nginx.conf are checked against a whitelist before any file is touched. The UI only ever sends on/off, numbers and sizes, but the endpoint is reachable directly, and an unfiltered value could inject a directive or trip the group-reference expansion of regexp.ReplaceAllString in the in-place rewrite.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
168
agent/app/service/nginx_gzip_upgrade.go
Normal file
168
agent/app/service/nginx_gzip_upgrade.go
Normal file
@@ -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")
|
||||
}
|
||||
182
agent/app/service/nginx_gzip_upgrade_test.go
Normal file
182
agent/app/service/nginx_gzip_upgrade_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
245
agent/app/service/nginx_http_config.go
Normal file
245
agent/app/service/nginx_http_config.go
Normal file
@@ -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
|
||||
}
|
||||
176
agent/app/service/nginx_http_config_test.go
Normal file
176
agent/app/service/nginx_http_config_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
483
agent/app/service/nginx_module_runtime.go
Normal file
483
agent/app/service/nginx_module_runtime.go
Normal file
@@ -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
|
||||
}
|
||||
231
agent/app/service/nginx_module_runtime_test.go
Normal file
231
agent/app/service/nginx_module_runtime_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
agent/app/service/nginx_module_static_test.go
Normal file
83
agent/app/service/nginx_module_static_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
@@ -218,6 +218,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'
|
||||
|
||||
@@ -218,6 +218,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'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: 'فرمت دامنه نامعتبر است: {{ .name }}'
|
||||
ErrDefaultAlias: 'default رزرو شده است؛ از نام مستعار دیگری استفاده کنید'
|
||||
ErrParentWebsite: 'ابتدا زیرسایت {{ .name }} را حذف کنید'
|
||||
ErrBuildDirNotFound: 'دایرکتوری ساخت وجود ندارد'
|
||||
ErrBrotliDisabled: 'ماژول brotli فعال نیست، ابتدا آن را فعال و بیلد کنید'
|
||||
ErrBrotliUnsupported: 'پنل نتوانست تنظیمات brotli را بهصورت خودکار به nginx.conf متصل کند؛ فایل را بررسی کنید یا OpenResty را ارتقا دهید'
|
||||
ErrModuleBuildUnsupported: 'این نسخه OpenResty نمیتواند ماژول بسازد، ابتدا آن را ارتقا دهید'
|
||||
ErrImageNotExist: 'تصویر محیط اجرا یافت نشد: {{ .name }}'
|
||||
ErrProxyIsUsed: 'تعادل بار توسط پراکسی معکوس استفاده میشود'
|
||||
ErrSSLValid: 'فایل گواهی نامعتبر است'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: '{{ .name }} ドメイン名の形式が正しくありませ
|
||||
ErrDefaultAlias: 'デフォルトは予約済みのコードです。別のコードを使用してください'
|
||||
ErrParentWebsite: 'まずサブサイト {{ .name }} を削除する必要があります'
|
||||
ErrBuildDirNotFound: 'ビルド ディレクトリが存在しません'
|
||||
ErrBrotliDisabled: 'brotli モジュールが有効になっていません。先に有効化してビルドしてください'
|
||||
ErrBrotliUnsupported: 'パネルは brotli 設定を nginx.conf に自動で組み込めませんでした。ファイルを確認するか OpenResty をアップグレードしてください'
|
||||
ErrModuleBuildUnsupported: 'この OpenResty バージョンではモジュールをビルドできません。先にアップグレードしてください'
|
||||
ErrImageNotExist: 'オペレーティング環境 {{ .name }} イメージが存在しません。オペレーティング環境を再編集してください'
|
||||
ErrProxyIsUsed: 'ロードバランシングはリバースプロキシによって使用されているため、削除できません'
|
||||
ErrSSLValid: '証明書ファイルが異常です、証明書の状態を確認してください!'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: '{{ .name }} 도메인 이름 형식이 올바르지 않습니
|
||||
ErrDefaultAlias: '기본값은 예약된 코드입니다. 다른 코드를 사용하세요'
|
||||
ErrParentWebsite: '먼저 하위 사이트 {{ .name }}을 삭제해야 합니다.'
|
||||
ErrBuildDirNotFound: '빌드 디렉토리가 존재하지 않습니다'
|
||||
ErrBrotliDisabled: 'brotli 모듈이 활성화되지 않았습니다. 먼저 활성화하고 빌드하세요'
|
||||
ErrBrotliUnsupported: '패널이 brotli 설정을 nginx.conf에 자동으로 연결할 수 없습니다. 파일을 확인하거나 OpenResty를 업그레이드하세요'
|
||||
ErrModuleBuildUnsupported: '현재 OpenResty 버전은 모듈을 빌드할 수 없습니다. 먼저 업그레이드하세요'
|
||||
ErrImageNotExist: '운영 환경 {{ .name }} 이미지가 존재하지 않습니다. 운영 환경을 다시 편집하세요.'
|
||||
ErrProxyIsUsed: '로드 밸런싱이 역방향 프록시에 의해 사용되었으므로 삭제할 수 없습니다'
|
||||
ErrSSLValid: '인증서 파일에 문제가 있습니다. 인증서 상태를 확인하세요'
|
||||
|
||||
@@ -208,6 +208,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: 'ໄຟລ໌ໃບຮັບຮອງບໍ່ຖືກຕ້ອງ'
|
||||
|
||||
@@ -218,6 +218,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'
|
||||
|
||||
@@ -218,6 +218,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'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: 'Неверный формат доменного имени {{
|
||||
ErrDefaultAlias: 'по умолчанию зарезервирован код, используйте другой код'
|
||||
ErrParentWebsite: 'Сначала вам необходимо удалить дочерний сайт {{ .name }}'
|
||||
ErrBuildDirNotFound: 'Каталог сборки не существует'
|
||||
ErrBrotliDisabled: 'Модуль brotli не включён, сначала включите и соберите его'
|
||||
ErrBrotliUnsupported: 'Панель не смогла автоматически подключить настройки brotli к nginx.conf; проверьте файл или обновите OpenResty'
|
||||
ErrModuleBuildUnsupported: 'Эта версия OpenResty не может собирать модули, сначала обновите её'
|
||||
ErrImageNotExist: 'Образ операционной среды {{ .name }} не существует, пожалуйста, отредактируйте операционную среду заново'
|
||||
ErrProxyIsUsed: 'Балансировка нагрузки используется обратным прокси, невозможно удалить'
|
||||
ErrSSLValid: 'Файл сертификата аномален, проверьте статус сертификата'
|
||||
|
||||
@@ -218,6 +218,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'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: '{{ .name }} 網域格式不正確'
|
||||
ErrDefaultAlias: 'default 為保留代號,請使用其他代號'
|
||||
ErrParentWebsite: '需要先移除子網站{{ .name }}'
|
||||
ErrBuildDirNotFound: '建置目錄不存在'
|
||||
ErrBrotliDisabled: 'brotli 模組未啟用,請先啟用並建置'
|
||||
ErrBrotliUnsupported: '面板無法自動將 brotli 設定寫入 nginx.conf,請檢查設定檔或升級 OpenResty'
|
||||
ErrModuleBuildUnsupported: '目前 OpenResty 版本無法建置模組,請先升級'
|
||||
ErrImageNotExist: '執行環境{{ .name }} 映像不存在,請重新編輯執行環境'
|
||||
ErrProxyIsUsed: '負載均衡已被反向代理使用,無法刪除'
|
||||
ErrSSLValid: '憑證檔案異常,請檢查憑證狀態!'
|
||||
|
||||
@@ -218,6 +218,9 @@ ErrDomainFormat: "{{ .name }} 域名格式不正确"
|
||||
ErrDefaultAlias: "default 为保留代号,请使用其他代号"
|
||||
ErrParentWebsite: "需要先删除子网站 {{ .name }}"
|
||||
ErrBuildDirNotFound: "构建目录不存在"
|
||||
ErrBrotliDisabled: "brotli 模块未启用,请先启用并构建"
|
||||
ErrBrotliUnsupported: "面板无法自动将 brotli 配置写入 nginx.conf,请检查配置文件或升级 OpenResty"
|
||||
ErrModuleBuildUnsupported: "当前 OpenResty 版本无法构建模块,请先升级"
|
||||
ErrImageNotExist: "运行环境 {{ .name }} 镜像不存在,请重新编辑运行环境"
|
||||
ErrProxyIsUsed: "负载均衡已被反向代理使用,无法删除"
|
||||
ErrSSLValid: '证书文件异常,请检查证书状态!'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,8 +6,8 @@ export const getNginx = () => {
|
||||
return http.get<File.File>(`/openresty`);
|
||||
};
|
||||
|
||||
export const getNginxConfigByScope = (req: Nginx.NginxScopeReq) => {
|
||||
return http.post<Nginx.NginxParam[]>(`/openresty/scope`, req);
|
||||
export const getNginxConfigByScope = <T = Nginx.NginxParam[]>(req: Nginx.NginxScopeReq) => {
|
||||
return http.post<T>(`/openresty/scope`, req);
|
||||
};
|
||||
|
||||
export const updateNginxConfigByScope = (req: Nginx.NginxConfigReq) => {
|
||||
|
||||
@@ -3913,6 +3913,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',
|
||||
|
||||
@@ -3960,6 +3960,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',
|
||||
|
||||
@@ -3880,6 +3880,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: 'مدیریت شده',
|
||||
|
||||
@@ -3902,6 +3902,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: '処理',
|
||||
|
||||
@@ -3831,6 +3831,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: '처리됨',
|
||||
|
||||
@@ -3807,6 +3807,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: 'ຈັດການແລ້ວ',
|
||||
|
||||
@@ -3975,6 +3975,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',
|
||||
|
||||
@@ -3994,6 +3994,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',
|
||||
|
||||
@@ -3962,6 +3962,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: 'Обработано',
|
||||
|
||||
@@ -3978,6 +3978,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',
|
||||
|
||||
@@ -3657,6 +3657,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)',
|
||||
|
||||
@@ -3702,6 +3702,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)',
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="client_header_buffer_size" prop="client_header_buffer_size">
|
||||
<el-input clearable type="number" v-model.number="form.client_header_buffer_size">
|
||||
<template #append>K</template>
|
||||
<template #append>{{ unitLabel('client_header_buffer_size', 'k') }}</template>
|
||||
</el-input>
|
||||
<span class="input-help">{{ $t('nginx.clientHeaderBufferSizeHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="client_max_body_size" prop="client_max_body_size">
|
||||
<el-input clearable type="number" v-model.number="form.client_max_body_size">
|
||||
<template #append>MB</template>
|
||||
<template #append>{{ unitLabel('client_max_body_size', 'm') }}</template>
|
||||
</el-input>
|
||||
<span class="input-help">{{ $t('nginx.clientMaxBodySizeHelper') }}</span>
|
||||
</el-form-item>
|
||||
@@ -38,7 +38,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="gzip_min_length" prop="gzip_min_length">
|
||||
<el-input clearable v-model.number="form.gzip_min_length">
|
||||
<template #append>KB</template>
|
||||
<template #append>{{ unitLabel('gzip_min_length', 'k') }}</template>
|
||||
</el-input>
|
||||
<span class="input-help">{{ $t('nginx.gzipMinLengthHelper') }}</span>
|
||||
</el-form-item>
|
||||
@@ -48,6 +48,39 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="brotliAvailable" v-loading="loading" :gutter="20">
|
||||
<el-col :span="24" v-if="brotliManagedExternally">
|
||||
<el-alert type="info" :closable="false" class="mb-2">
|
||||
{{ $t('nginx.brotliManagedExternallyHelper') }}
|
||||
</el-alert>
|
||||
</el-col>
|
||||
<el-col :span="24" v-if="brotliManagedUnavailable">
|
||||
<el-alert type="warning" :closable="false" class="mb-2">
|
||||
{{ $t('nginx.brotliManagedUnavailableHelper') }}
|
||||
</el-alert>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="9" :lg="9" :xl="9">
|
||||
<el-form-item label="brotli" prop="brotli">
|
||||
<el-select v-model="brotliForm.brotli">
|
||||
<el-option :label="'on'" :value="'on'"></el-option>
|
||||
<el-option :label="'off'" :value="'off'"></el-option>
|
||||
</el-select>
|
||||
<span class="input-help">{{ $t('nginx.brotliHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="brotli_min_length" prop="brotli_min_length">
|
||||
<el-input clearable v-model.number="brotliForm.brotli_min_length">
|
||||
<template #append>{{ unitLabel('brotli_min_length', 'k') }}</template>
|
||||
</el-input>
|
||||
<span class="input-help">{{ $t('nginx.brotliMinLengthHelper') }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="9" :lg="9" :xl="9">
|
||||
<el-form-item label="brotli_comp_level" prop="brotli_comp_level">
|
||||
<el-input clearable v-model.number="brotliForm.brotli_comp_level"></el-input>
|
||||
<span class="input-help">{{ $t('nginx.brotliCompLevelHelper') }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item>
|
||||
<el-button v-permission type="primary" @click="submit(nginxFormRef)">
|
||||
{{ $t('commons.button.save') }}
|
||||
@@ -58,10 +91,10 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Nginx } from '@/api/interface/nginx';
|
||||
import { getNginxConfigByScope, updateNginxConfigByScope } from '@/api/modules/nginx';
|
||||
import { getNginxConfigByScope, getNginxModules, updateNginxConfigByScope } from '@/api/modules/nginx';
|
||||
import { checkNumberRange, Rules } from '@/global/form-rules';
|
||||
import i18n from '@/lang';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { FormInstance } from 'element-plus';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
@@ -86,6 +119,18 @@ let form = ref({
|
||||
let nginxFormRef = ref();
|
||||
let loading = ref(false);
|
||||
|
||||
// Brotli is only offered once the module is enabled and built. Its directives
|
||||
// live in a panel-managed file rather than nginx.conf, because they have to
|
||||
// disappear together with the module.
|
||||
const brotliAvailable = ref(false);
|
||||
const brotliManagedExternally = ref(false);
|
||||
const brotliManagedUnavailable = ref(false);
|
||||
const brotliForm = ref({
|
||||
brotli: 'on',
|
||||
brotli_comp_level: 5,
|
||||
brotli_min_length: 1,
|
||||
});
|
||||
|
||||
const variablesRules = reactive({
|
||||
server_names_hash_bucket_size: [checkNumberRange(1, 9999)],
|
||||
client_header_buffer_size: [checkNumberRange(0, 999999999)],
|
||||
@@ -94,9 +139,42 @@ const variablesRules = reactive({
|
||||
gzip: [Rules.requiredSelect],
|
||||
gzip_min_length: [Rules.requiredSelect],
|
||||
gzip_comp_level: [checkNumberRange(1, 9)],
|
||||
brotli: [Rules.requiredSelect],
|
||||
brotli_min_length: [Rules.requiredSelect],
|
||||
brotli_comp_level: [checkNumberRange(0, 11)],
|
||||
});
|
||||
|
||||
// nginx size directives may be written with or without a unit suffix, and
|
||||
// the suffix carries a factor of 1024. Remember the unit that was read so it
|
||||
// can be written back unchanged, instead of assuming a fixed one.
|
||||
const sizeKeys = ['client_header_buffer_size', 'client_max_body_size', 'gzip_min_length', 'brotli_min_length'];
|
||||
const units = ref<Record<string, string>>({});
|
||||
|
||||
const parseSizeParam = (name: string, value: string) => {
|
||||
const matched = /^(\d+)\s*([kKmMgG]?)$/.exec(value.trim());
|
||||
if (!matched) {
|
||||
return Number(value.match(/\d+/g)?.[0] ?? 0);
|
||||
}
|
||||
units.value[name] = matched[2] === '' ? '' : matched[2].toLowerCase();
|
||||
return Number(matched[1]);
|
||||
};
|
||||
|
||||
const withUnit = (name: string, value: number, defaultUnit: string) => {
|
||||
const unit = units.value[name] ?? defaultUnit;
|
||||
return String(value) + unit;
|
||||
};
|
||||
|
||||
// Label the input with the unit actually in use, so a value stored in bytes
|
||||
// is not presented as if it were kilobytes.
|
||||
const unitLabel = (name: string, defaultUnit: string) => {
|
||||
const unit = units.value[name] ?? defaultUnit;
|
||||
return unit === '' ? 'B' : unit.toUpperCase();
|
||||
};
|
||||
|
||||
const getParams = async () => {
|
||||
// Reset the unit cache: a directive removed since the last load must not
|
||||
// hand its remembered unit to a value that no longer carries one.
|
||||
units.value = {};
|
||||
const res = await getNginxConfigByScope(req.value);
|
||||
data.value = res.data;
|
||||
for (const param of res.data) {
|
||||
@@ -105,8 +183,37 @@ const getParams = async () => {
|
||||
}
|
||||
if (param.name == 'gzip') {
|
||||
form.value.gzip = param.params[0];
|
||||
} else {
|
||||
form.value[param.name] = Number(param.params[0].match(/\d+/g));
|
||||
} else if (sizeKeys.includes(param.name)) {
|
||||
form.value[param.name] = parseSizeParam(param.name, param.params[0]);
|
||||
} else if (param.name in form.value) {
|
||||
form.value[param.name] = Number(param.params[0].match(/\d+/g)?.[0] ?? 0);
|
||||
}
|
||||
// Keys the page does not render (gzip_types, gzip_vary, gzip_proxied)
|
||||
// are deliberately not stuffed into the form.
|
||||
}
|
||||
await getBrotliParams();
|
||||
};
|
||||
|
||||
const getBrotliParams = async () => {
|
||||
const modules = await getNginxModules();
|
||||
const brotli = modules.data.modules?.find((item) => item.name === 'ngx_brotli');
|
||||
brotliAvailable.value = !!brotli && brotli.enable && brotli.buildStatus === 'ready';
|
||||
if (!brotliAvailable.value) {
|
||||
return;
|
||||
}
|
||||
const res = await getNginxConfigByScope<Nginx.NginxBrotliRes>({ scope: 'brotli' });
|
||||
brotliManagedExternally.value = !!res.data?.managedExternally;
|
||||
brotliManagedUnavailable.value = !!res.data?.managedUnavailable;
|
||||
for (const param of res.data?.params ?? []) {
|
||||
if (param.params.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (param.name === 'brotli') {
|
||||
brotliForm.value.brotli = param.params[0];
|
||||
} else if (param.name === 'brotli_min_length') {
|
||||
brotliForm.value.brotli_min_length = parseSizeParam(param.name, param.params[0]);
|
||||
} else if (param.name === 'brotli_comp_level') {
|
||||
brotliForm.value.brotli_comp_level = Number(param.params[0].match(/\d+/g)?.[0] ?? 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -121,18 +228,41 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
let params = {
|
||||
gzip: form.value.gzip,
|
||||
server_names_hash_bucket_size: String(form.value.server_names_hash_bucket_size),
|
||||
client_header_buffer_size: String(form.value.client_header_buffer_size) + 'k',
|
||||
client_max_body_size: String(form.value.client_max_body_size) + 'm',
|
||||
client_header_buffer_size: withUnit('client_header_buffer_size', form.value.client_header_buffer_size, 'k'),
|
||||
client_max_body_size: withUnit('client_max_body_size', form.value.client_max_body_size, 'm'),
|
||||
keepalive_timeout: String(form.value.keepalive_timeout),
|
||||
gzip_min_length: String(form.value.gzip_min_length) + 'k',
|
||||
gzip_min_length: withUnit('gzip_min_length', form.value.gzip_min_length, 'k'),
|
||||
gzip_comp_level: String(form.value.gzip_comp_level),
|
||||
};
|
||||
updateReq.value.params = params;
|
||||
updateNginxConfigByScope(updateReq.value)
|
||||
.then(() => {
|
||||
if (!brotliAvailable.value || brotliManagedUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
// brotli_types is managed by the panel and deliberately not
|
||||
// exposed, so it stays aligned with gzip_types.
|
||||
return updateNginxConfigByScope({
|
||||
scope: 'brotli',
|
||||
operate: 'update',
|
||||
params: {
|
||||
brotli: brotliForm.value.brotli,
|
||||
brotli_comp_level: String(brotliForm.value.brotli_comp_level),
|
||||
brotli_min_length: withUnit('brotli_min_length', brotliForm.value.brotli_min_length, 'k'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// gzip was already saved by the time brotli failed; say so
|
||||
// instead of leaving the user guessing which half applied.
|
||||
MsgError(i18n.global.t('nginx.brotliSaveFailed'));
|
||||
getParams();
|
||||
throw new Error('brotli save failed');
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.updateSuccess'));
|
||||
getParams();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user