Files
1Panel/agent/app/service/nginx_module_runtime.go
T
Kadxy 37170ae81a fix(openresty): report brotli as pending when the http.d include can be added (#14285)
Installs that enabled ngx_brotli before v2.3.0 have no http.d include in
nginx.conf, because the include is only inserted when a module is toggled
or built. The settings page reported this as "cannot write the managed
configuration" and skipped the brotli save, so the save path that inserts
the include was never reached, even on a stock nginx.conf.

The settings response now tells the two cases apart with an in-memory
insertion check that writes nothing:

- include missing but insertable, or managed file missing: managedPending,
  the page explains the values take effect after saving and saves normally
- no safe insertion point: managedUnavailable, unchanged

The save path now inserts the include through ensureNginxHTTPIncludeActive,
the same function module activation uses, so both produce identical
results. A sentinel error keeps "no insertion point" mapped to
ErrBrotliUnsupported while I/O failures are still returned as-is.
2026-11-03 18:58:24 +08:00

496 lines
19 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}
if !managedExternally {
res.ManagedPending, res.ManagedUnavailable = nginxBrotliManagedState(install)
}
for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) {
res.Params = append(res.Params, response.NginxParam{Name: directive.Name, Params: directive.Params})
}
return res, nil
}
// nginxBrotliManagedState reports whether the managed brotli file is missing
// from what nginx loads, and if so whether saving from the settings page can
// fix that. Either way the reported values are not in effect, so they must
// not be presented as if they were.
//
// A missing include is the common case for installs that enabled brotli
// before the panel managed its directives, and the save path inserts it. Only
// a config without a safe insertion point is reported as unavailable.
func nginxBrotliManagedState(install model.AppInstall) (pending, unavailable bool) {
if !nginxHTTPIncludePresent(install) {
if nginxHTTPIncludeInsertable(install) {
return true, false
}
return false, true
}
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
if _, err := os.Stat(path.Join(nginxHTTPConfigDir(install), fileName)); os.IsNotExist(err) {
return true, false
}
return false, false
}
// 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, through the same path that enabling the
// module takes; when nginx.conf cannot be edited safely the write is
// refused with an actionable error instead of writing values nginx
// would never load.
_, confSnapshot, includeErr := ensureNginxHTTPIncludeActive(install)
if includeErr != nil {
if errors.Is(includeErr, errNginxHTTPIncludeNoInsertionPoint) {
return buserr.New("ErrBrotliUnsupported")
}
return includeErr
}
if confSnapshot != nil {
if err = nginxCheckAndReload(string(confSnapshot), nginxMainConfigPath(install), 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
}