Files
1Panel/agent/app/service/nginx_module_runtime.go
Snrat 70fc628c81 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.
2026-09-07 14:42:03 +08:00

484 lines
18 KiB
Go

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
}