diff --git a/agent/app/dto/nginx.go b/agent/app/dto/nginx.go index 36308105d0aa..7d3ccfd3778b 100644 --- a/agent/app/dto/nginx.go +++ b/agent/app/dto/nginx.go @@ -1,6 +1,8 @@ package dto import ( + "time" + "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/utils/nginx/components" ) @@ -87,9 +89,41 @@ var LBAlgorithms = map[string]struct{}{"ip_hash": {}, "least_conn": {}} var RealIPKeys = map[string]struct{}{"X-Forwarded-For": {}, "X-Real-IP": {}, "CF-Connecting-IP": {}} type NginxModule struct { - Name string `json:"name"` - Script string `json:"script"` - Packages []string `json:"packages"` - Params string `json:"params"` - Enable bool `json:"enable"` + Name string `json:"name"` + Script string `json:"script"` + Packages []string `json:"packages"` + Params string `json:"params"` + Enable bool `json:"enable"` + Deleted bool `json:"deleted,omitempty"` + BuildMode string `json:"buildMode,omitempty"` + Provider string `json:"provider,omitempty"` + DynamicSupport string `json:"dynamicSupport,omitempty"` + LoadOrder int `json:"loadOrder,omitempty"` + Builds []NginxModuleBuild `json:"builds,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type NginxModuleBuild struct { + Provider string `json:"provider"` + Status string `json:"status"` + Hash string `json:"hash"` + Target NginxModuleTarget `json:"target"` + Artifacts []NginxModuleArtifact `json:"artifacts,omitempty"` + Error string `json:"error,omitempty"` + BuiltAt time.Time `json:"builtAt,omitempty"` +} + +type NginxModuleTarget struct { + Key string `json:"key"` + OpenRestyVersion string `json:"openrestyVersion"` + Architecture string `json:"architecture"` + Image string `json:"image,omitempty"` + ImageDigest string `json:"imageDigest,omitempty"` + BuilderDigest string `json:"builderDigest,omitempty"` +} + +type NginxModuleArtifact struct { + Name string `json:"name"` + Path string `json:"path"` + Checksum string `json:"checksum"` } diff --git a/agent/app/dto/request/nginx.go b/agent/app/dto/request/nginx.go index 7c70613b9ca5..9624e00d4547 100644 --- a/agent/app/dto/request/nginx.go +++ b/agent/app/dto/request/nginx.go @@ -114,17 +114,22 @@ type NginxRedirectUpdate struct { } type NginxBuildReq struct { - TaskID string `json:"taskID" validate:"required"` - Mirror string `json:"mirror" validate:"required"` + TaskID string `json:"taskID" validate:"required"` + Mirror string `json:"mirror" validate:"required"` + Modules []string `json:"modules"` + Force bool `json:"force"` } type NginxModuleUpdate struct { - Operate string `json:"operate" validate:"required,oneof=create delete update"` - Name string `json:"name" validate:"required"` - Script string `json:"script"` - Packages string `json:"packages"` - Enable bool `json:"enable"` - Params string `json:"params"` + Operate string `json:"operate" validate:"required,oneof=create delete update"` + Name string `json:"name" validate:"required"` + Script string `json:"script"` + Packages string `json:"packages"` + Enable bool `json:"enable"` + Params string `json:"params"` + BuildMode string `json:"buildMode" validate:"omitempty,oneof=auto dynamic static"` + Provider string `json:"provider" validate:"omitempty,oneof=local prebuilt"` + LoadOrder int `json:"loadOrder" validate:"omitempty,min=0,max=9999"` } type NginxOperateReq struct { diff --git a/agent/app/dto/response/nginx.go b/agent/app/dto/response/nginx.go index 1e0b0e0a940e..6408bd15266e 100644 --- a/agent/app/dto/response/nginx.go +++ b/agent/app/dto/response/nginx.go @@ -69,16 +69,26 @@ type NginxProxyCache struct { } type NginxModule struct { - Name string `json:"name"` - Script string `json:"script"` - Packages string `json:"packages"` - Params string `json:"params"` - Enable bool `json:"enable"` + Name string `json:"name"` + Script string `json:"script"` + Packages string `json:"packages"` + Params string `json:"params"` + Enable bool `json:"enable"` + BuildMode string `json:"buildMode"` + Provider string `json:"provider"` + DynamicSupport string `json:"dynamicSupport"` + LoadOrder int `json:"loadOrder"` + BuildStatus string `json:"buildStatus"` + LoadStatus string `json:"loadStatus"` + Compatibility string `json:"compatibility"` + Artifacts []dto.NginxModuleArtifact `json:"artifacts"` + LastError string `json:"lastError"` } type NginxBuildConfig struct { - Mirror string `json:"mirror"` - Modules []NginxModule `json:"modules"` + Mirror string `json:"mirror"` + DynamicSupported bool `json:"dynamicSupported"` + Modules []NginxModule `json:"modules"` } type NginxConfigRes struct { diff --git a/agent/app/service/app_utils.go b/agent/app/service/app_utils.go index 64175c15d6e0..2cb90e77386e 100644 --- a/agent/app/service/app_utils.go +++ b/agent/app/service/app_utils.go @@ -1,7 +1,6 @@ package service import ( - "bufio" "context" "encoding/base64" "encoding/json" @@ -653,11 +652,58 @@ func handleUpgradeCompose(install model.AppInstall, detail model.AppDetail) (map if oldServiceValue["restart"] != nil { serviceValue["restart"] = oldServiceValue["restart"] } + if install.App.Key == constant.AppOpenresty { + mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue) + } servicesMap[install.ServiceName] = serviceValue composeMap["services"] = servicesMap return composeMap, nil } +// mergeOpenrestyModuleVolumes carries the dynamic module mounts of the old +// compose over to the upgraded one when it does not declare them, so built +// module artifacts and their load configuration stay mounted across upgrades. +func mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue map[string]interface{}) { + oldVolumes, ok := oldServiceValue["volumes"].([]interface{}) + if !ok { + return + } + newVolumes, _ := serviceValue["volumes"].([]interface{}) + existing := make(map[string]struct{}, len(newVolumes)) + for _, volume := range newVolumes { + if containerPath, ok := composeVolumeContainerPath(volume); ok { + existing[containerPath] = struct{}{} + } + } + for _, volume := range oldVolumes { + containerPath, ok := composeVolumeContainerPath(volume) + if !ok { + continue + } + if !strings.Contains(containerPath, nginxModuleEnabledConfDir) && !strings.Contains(containerPath, "nginx/modules/1panel") { + continue + } + if _, ok = existing[containerPath]; ok { + continue + } + newVolumes = append(newVolumes, volume) + existing[containerPath] = struct{}{} + } + serviceValue["volumes"] = newVolumes +} + +func composeVolumeContainerPath(volume interface{}) (string, bool) { + volumeStr, ok := volume.(string) + if !ok { + return "", false + } + parts := strings.Split(volumeStr, ":") + if len(parts) < 2 { + return "", false + } + return parts[1], true +} + func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string, error) { if detail.DockerCompose == "" { return "", nil @@ -691,74 +737,35 @@ func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string return string(composeByte), nil } -func buildNginx(parentTask *task.Task) error { - nginxInstall, err := getAppInstallByKey(constant.AppOpenresty) - if err != nil { - return err - } +func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall) error { fileOp := files.NewFileOp() - buildPath := path.Join(nginxInstall.GetPath(), "build") + buildPath := path.Join(nginxInstall.GetPath(), nginxModuleBuildDir) if !fileOp.Stat(buildPath) { return buserr.New("ErrBuildDirNotFound") } - moduleConfigPath := path.Join(buildPath, "module.json") - moduleContent, err := fileOp.GetContent(moduleConfigPath) + modules, err := loadNginxModules(nginxInstall) if err != nil { return err } - var ( - modules []dto.NginxModule - addModuleParams []string - addPackages []string - ) - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) - bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm) - if err != nil { - return err - } - defer bashFile.Close() - bashFileWriter := bufio.NewWriter(bashFile) - for _, module := range modules { - if !module.Enable { - continue - } - _, err = bashFileWriter.WriteString(module.Script + "\n") - if err != nil { - return err - } - addModuleParams = append(addModuleParams, module.Params) - addPackages = append(addPackages, module.Packages...) - } - err = bashFileWriter.Flush() - if err != nil { + previousModules := cloneNginxModules(modules) + staticBuild := hasEnabledStaticNginxModules(modules) + if err = configureStaticNginxModules(nginxInstall, modules, ""); err != nil { + return err + } + if staticBuild { + logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image")) + parentTask.LogStart(logStr) + cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute)) + if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { return err } + parentTask.LogSuccess(logStr) } - envs, err := gotenv.Read(nginxInstall.GetEnvPath()) + modules, err = buildDynamicNginxModules(nginxInstall, modules, nil, false, parentTask) if err != nil { return err } - envs["RESTY_CONFIG_OPTIONS_MORE"] = "" - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = "" - if len(addModuleParams) > 0 { - envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ") - } - if len(addPackages) > 0 { - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ") - } - _ = gotenv.Write(envs, nginxInstall.GetEnvPath()) - if len(addModuleParams) == 0 && len(addPackages) == 0 { - return nil - } - logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image")) - parentTask.LogStart(logStr) - cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(60*time.Minute)) - if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { - return err - } - parentTask.LogSuccess(logStr) - return nil + return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false) } func upgradeInstall(req request.AppInstallUpgrade) error { @@ -858,22 +865,32 @@ func upgradeInstall(req request.AppInstallUpgrade) error { } _ = copyAppDetailMissing(fileOp, detailDir, install.GetPath()) if install.App.Key == constant.AppOpenresty { - installBuildDir := path.Join(install.GetPath(), "build") - detailBuildDir := path.Join(detailDir, "build") + installBuildDir := path.Join(install.GetPath(), nginxModuleBuildDir) + detailBuildDir := path.Join(detailDir, nginxModuleBuildDir) if !fileOp.Stat(installBuildDir) { if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil { return err } } - if err := fileOp.DeleteDir(path.Join(installBuildDir, "tmp")); err != nil { + if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil { return err } - if err := fileOp.CopyDir(path.Join(detailBuildDir, "tmp"), installBuildDir); err != nil { + if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil { return err } if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil { return err } + if fileOp.Stat(path.Join(detailBuildDir, nginxModuleBuilderFile)) { + if err := fileOp.CopyFile(path.Join(detailBuildDir, nginxModuleBuilderFile), installBuildDir); err != nil { + return err + } + } + if fileOp.Stat(path.Join(detailBuildDir, nginxModuleCatalogFile)) { + if err := fileOp.CopyFile(path.Join(detailBuildDir, nginxModuleCatalogFile), installBuildDir); err != nil { + return err + } + } if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil { return err } @@ -951,6 +968,26 @@ func upgradeInstall(req request.AppInstallUpgrade) error { } } + if install.App.Key == constant.AppOpenresty { + modules, moduleErr := loadNginxModules(install) + if moduleErr != nil { + return moduleErr + } + // Build dynamic modules for the target version before stopping the + // current container. Static modules retain the full rebuild path. + if !hasEnabledStaticNginxModules(modules) { + previousModules := cloneNginxModules(modules) + modules, moduleErr = buildDynamicNginxModules(install, modules, nil, false, t) + if moduleErr != nil { + return moduleErr + } + if moduleErr = saveNginxModules(install, modules); moduleErr != nil { + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return moduleErr + } + } + } + if out, err := compose.Down(install.GetComposePath()); err != nil { if out != "" { upErr = errors.New(out) @@ -985,7 +1022,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error { } if install.App.Key == constant.AppOpenresty { - if err = buildNginx(t); err != nil { + if err = buildNginx(t, install); err != nil { t.Log(err.Error()) return err } @@ -2285,7 +2322,7 @@ func handleOpenrestyFile(appInstall *model.AppInstall) error { func handleDefaultServer(appInstall *model.AppInstall) error { installDir := appInstall.GetPath() - defaultConfigPath := path.Join(installDir, "conf", "default", "00.default.conf") + defaultConfigPath := path.Join(installDir, nginxModuleConfDir, "default", "00.default.conf") fileOp := files.NewFileOp() content, err := fileOp.GetContent(defaultConfigPath) if err != nil { @@ -2299,7 +2336,7 @@ func handleDefaultServer(appInstall *model.AppInstall) error { } func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRejectHandshake bool) error { - sslDir := path.Join(appInstall.GetPath(), "conf", "ssl") + sslDir := path.Join(appInstall.GetPath(), nginxModuleConfDir, "ssl") fileOp := files.NewFileOp() if !fileOp.Stat(sslDir) { return errors.New("ssl dir not found") @@ -2329,7 +2366,7 @@ func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRe _ = NewIWebsiteSSLService().Delete([]uint{websiteSSL.ID}) }() } - defaultConfigPath := path.Join(appInstall.GetPath(), "conf", "default", "00.default.conf") + defaultConfigPath := path.Join(appInstall.GetPath(), nginxModuleConfDir, "default", "00.default.conf") content, err := os.ReadFile(defaultConfigPath) if err != nil { return err diff --git a/agent/app/service/nginx.go b/agent/app/service/nginx.go index d5c0446169a8..989aa5b0d39c 100644 --- a/agent/app/service/nginx.go +++ b/agent/app/service/nginx.go @@ -1,8 +1,6 @@ package service import ( - "bufio" - "encoding/json" "fmt" "github.com/1Panel-dev/1Panel/agent/utils/nginx" "github.com/1Panel-dev/1Panel/agent/utils/nginx/parser" @@ -17,7 +15,6 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/global" - "github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/subosito/gotenv" "github.com/1Panel-dev/1Panel/agent/utils/compose" @@ -57,7 +54,7 @@ func (n NginxService) GetNginxConfig() (*response.NginxFile, error) { if err != nil { return nil, err } - configPath := path.Join(global.Dir.AppInstallDir, constant.AppOpenresty, nginxInstall.Name, "conf", "nginx.conf") + configPath := path.Join(global.Dir.AppInstallDir, constant.AppOpenresty, nginxInstall.Name, nginxModuleConfDir, "nginx.conf") byteContent, err := files.NewFileOp().GetContent(configPath) if err != nil { return nil, err @@ -138,7 +135,7 @@ func (n NginxService) UpdateConfigFile(req request.NginxConfigFileUpdate) error if err != nil { return err } - filePath := path.Join(global.Dir.AppInstallDir, constant.AppOpenresty, nginxInstall.Name, "conf", "nginx.conf") + filePath := path.Join(global.Dir.AppInstallDir, constant.AppOpenresty, nginxInstall.Name, nginxModuleConfDir, "nginx.conf") if req.Backup { backupPath := path.Join(path.Dir(filePath), "bak") if !fileOp.Stat(backupPath) { @@ -181,71 +178,20 @@ func (n NginxService) Build(req request.NginxBuildReq) error { if err = task.CheckTaskIsExecuting(taskName); err != nil { return err } - fileOp := files.NewFileOp() - buildPath := path.Join(nginxInstall.GetPath(), "build") - if !fileOp.Stat(buildPath) { - return buserr.New("ErrBuildDirNotFound") - } - moduleConfigPath := path.Join(buildPath, "module.json") - moduleContent, err := fileOp.GetContent(moduleConfigPath) - if err != nil { + if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, nginxInstall.ID); err != nil { return err } - var ( - modules []dto.NginxModule - addModuleParams []string - addPackages []string - ) - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) - bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm) - if err != nil { - return err - } - defer bashFile.Close() - bashFileWriter := bufio.NewWriter(bashFile) - for _, module := range modules { - if !module.Enable { - continue - } - _, err = bashFileWriter.WriteString(module.Script + "\n") - if err != nil { - return err - } - addModuleParams = append(addModuleParams, module.Params) - addPackages = append(addPackages, module.Packages...) - } - err = bashFileWriter.Flush() - if err != nil { - return err - } - } - envs, err := gotenv.Read(nginxInstall.GetEnvPath()) - if err != nil { - return err - } - envs["CONTAINER_PACKAGE_URL"] = req.Mirror - envs["RESTY_CONFIG_OPTIONS_MORE"] = "" - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = "" - if len(addModuleParams) > 0 { - envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ") - } - if len(addPackages) > 0 { - envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ") + buildPath := path.Join(nginxInstall.GetPath(), nginxModuleBuildDir) + if !files.NewFileOp().Stat(buildPath) { + return buserr.New("ErrBuildDirNotFound") } - _ = gotenv.Write(envs, nginxInstall.GetEnvPath()) buildTask, err := task.NewTaskWithOps(nginxInstall.Name, task.TaskBuild, task.TaskScopeApp, req.TaskID, nginxInstall.ID) if err != nil { return err } buildTask.AddSubTaskWithOps("", func(t *task.Task) error { - cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*buildTask), cmd.WithTimeout(120*time.Minute)) - if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil { - return err - } - _, err = compose.DownAndUp(nginxInstall.GetComposePath()) - return err + return executeNginxModuleBuild(nginxInstall, req.Modules, req.Force, req.Mirror, t, true) }, nil, 0, 120*time.Minute) go func() { @@ -259,27 +205,65 @@ func (n NginxService) GetModules() (*response.NginxBuildConfig, error) { if err != nil { return nil, err } - fileOp := files.NewFileOp() - var modules []dto.NginxModule - moduleConfigPath := path.Join(nginxInstall.GetPath(), "build", "module.json") - if !fileOp.Stat(moduleConfigPath) { - return nil, nil - } - moduleContent, err := fileOp.GetContent(moduleConfigPath) + modules, err := loadNginxModules(nginxInstall) if err != nil { return nil, err } - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) + target, targetWarning, targetErr := resolveNginxModuleTarget(nginxInstall) + if targetWarning != "" { + global.LOG.Warn(targetWarning) } var resList []response.NginxModule for _, module := range modules { + if module.Deleted { + continue + } + buildStatus := nginxModuleStatusPending + loadStatus := nginxModuleLoadDisabled + compatibility := "unknown" + var artifacts []dto.NginxModuleArtifact + if module.BuildMode == nginxModuleBuildStatic { + buildStatus = nginxModuleStatusReady + compatibility = "static" + if module.Enable { + loadStatus = nginxModuleLoadEnabled + } + } else if targetErr == nil { + if build := findCurrentNginxModuleBuild(module, target); build != nil { + buildStatus = build.Status + artifacts = build.Artifacts + if build.Status == nginxModuleStatusReady { + compatibility = "compatible" + if module.Enable { + loadStatus = nginxModuleLoadEnabled + } + } + } else if latestBuild := findLatestNginxModuleBuild(module, target); latestBuild != nil { + compatibility = "stale" + artifacts = latestBuild.Artifacts + if module.Enable { + loadStatus = nginxModuleLoadEnabled + } + } + } + if module.BuildMode != nginxModuleBuildStatic && module.LastError != "" { + buildStatus = nginxModuleStatusFailed + } resList = append(resList, response.NginxModule{ - Name: module.Name, - Script: module.Script, - Packages: strings.Join(module.Packages, ","), - Params: module.Params, - Enable: module.Enable, + Name: module.Name, + Script: module.Script, + Packages: strings.Join(module.Packages, ","), + Params: module.Params, + Enable: module.Enable, + BuildMode: module.BuildMode, + Provider: module.Provider, + DynamicSupport: module.DynamicSupport, + LoadOrder: module.LoadOrder, + BuildStatus: buildStatus, + LoadStatus: loadStatus, + Compatibility: compatibility, + Artifacts: artifacts, + LastError: module.LastError, }) } envs, err := gotenv.Read(nginxInstall.GetEnvPath()) @@ -288,8 +272,9 @@ func (n NginxService) GetModules() (*response.NginxBuildConfig, error) { } return &response.NginxBuildConfig{ - Mirror: envs["CONTAINER_PACKAGE_URL"], - Modules: resList, + Mirror: envs["CONTAINER_PACKAGE_URL"], + DynamicSupported: nginxModuleDynamicSupported(nginxInstall), + Modules: resList, }, nil } @@ -298,59 +283,91 @@ func (n NginxService) UpdateModule(req request.NginxModuleUpdate) error { if err != nil { return err } - fileOp := files.NewFileOp() - var ( - modules []dto.NginxModule - ) - moduleConfigPath := path.Join(nginxInstall.GetPath(), "build", "module.json") - if !fileOp.Stat(moduleConfigPath) { - _ = fileOp.CreateFile(moduleConfigPath) - } - moduleContent, err := fileOp.GetContent(moduleConfigPath) - if err != nil { + if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, nginxInstall.ID); err != nil { return err } - if len(moduleContent) > 0 { - _ = json.Unmarshal(moduleContent, &modules) + modules, err := loadNginxModules(nginxInstall) + if err != nil { + return err } + oldModules := cloneNginxModules(modules) + var deletedModule *dto.NginxModule switch req.Operate { - case "create": - for _, module := range modules { + case nginxModuleOperateCreate: + recreated := false + for i, module := range modules { if module.Name == req.Name { + if module.Deleted { + modules[i] = dto.NginxModule{ + Name: req.Name, Script: req.Script, Packages: strings.Split(req.Packages, ","), + Params: req.Params, + Enable: req.Enable, BuildMode: req.BuildMode, Provider: req.Provider, LoadOrder: req.LoadOrder, + } + recreated = true + break + } return buserr.New("ErrNameIsExist") } } - modules = append(modules, dto.NginxModule{ - Name: req.Name, - Script: req.Script, - Packages: strings.Split(req.Packages, ","), - Params: req.Params, - Enable: true, - }) - case "update": + if !recreated { + modules = append(modules, dto.NginxModule{ + Name: req.Name, + Script: req.Script, + Packages: strings.Split(req.Packages, ","), + Params: req.Params, + Enable: req.Enable, + BuildMode: req.BuildMode, + Provider: req.Provider, + LoadOrder: req.LoadOrder, + }) + } + case nginxModuleOperateUpdate: + found := false for i, module := range modules { if module.Name == req.Name { + found = true modules[i].Script = req.Script modules[i].Packages = strings.Split(req.Packages, ",") modules[i].Params = req.Params modules[i].Enable = req.Enable + modules[i].BuildMode = req.BuildMode + modules[i].Provider = req.Provider + modules[i].LoadOrder = req.LoadOrder break } } - case "delete": + if !found { + return fmt.Errorf("OpenResty module %s not found", req.Name) + } + case nginxModuleOperateDelete: + found := false for i, module := range modules { if module.Name == req.Name { - modules = append(modules[:i], modules[i+1:]...) + found = true + moduleCopy := module + deletedModule = &moduleCopy + modules[i].Deleted = true + modules[i].Enable = false break } } + if !found { + return fmt.Errorf("OpenResty module %s not found", req.Name) + } } - moduleByte, err := json.Marshal(modules) - if err != nil { + if err = saveNginxModules(nginxInstall, modules); err != nil { + return err + } + if err = reconcileDynamicNginxModuleConfig(nginxInstall, modules, true); err != nil { + _ = saveNginxModules(nginxInstall, oldModules) + _ = reconcileDynamicNginxModuleConfig(nginxInstall, oldModules, false) return err } - return fileOp.SaveFileWithByte(moduleConfigPath, moduleByte, constant.DirPerm) + if deletedModule != nil { + _ = removeNginxModuleArtifacts(nginxInstall, *deletedModule) + } + return nil } func (n NginxService) OperateDefaultHTTPs(req request.NginxDefaultHTTPSUpdate) error { @@ -366,7 +383,7 @@ func (n NginxService) OperateDefaultHTTPs(req request.NginxDefaultHTTPSUpdate) e break } } - defaultConfigPath := path.Join(appInstall.GetPath(), "conf", "default", "00.default.conf") + defaultConfigPath := path.Join(appInstall.GetPath(), nginxModuleConfDir, "default", "00.default.conf") content, err := os.ReadFile(defaultConfigPath) if err != nil { return err @@ -406,7 +423,7 @@ func (n NginxService) GetDefaultHttpsStatus() (*response.NginxConfigRes, error) if err != nil { return nil, err } - defaultConfigPath := path.Join(appInstall.GetPath(), "conf", "default", "00.default.conf") + defaultConfigPath := path.Join(appInstall.GetPath(), nginxModuleConfDir, "default", "00.default.conf") content, err := os.ReadFile(defaultConfigPath) if err != nil { return nil, err diff --git a/agent/app/service/nginx_module.go b/agent/app/service/nginx_module.go new file mode 100644 index 000000000000..df5096ca9ec7 --- /dev/null +++ b/agent/app/service/nginx_module.go @@ -0,0 +1,1095 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "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/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/cmd" + "github.com/1Panel-dev/1Panel/agent/utils/compose" + dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/1Panel-dev/1Panel/agent/utils/files" + "github.com/1Panel-dev/1Panel/agent/utils/re" + "github.com/mattn/go-shellwords" + "github.com/subosito/gotenv" +) + +const ( + nginxModuleBuildAuto = "auto" + nginxModuleBuildDynamic = "dynamic" + nginxModuleBuildStatic = "static" + + nginxModuleProviderLocal = "local" + + nginxModuleSupportUnknown = "unknown" + nginxModuleSupportSupported = "supported" + nginxModuleSupportUnsupported = "unsupported" + + nginxModuleStatusPending = "pending" + nginxModuleStatusReady = "ready" + nginxModuleStatusFailed = "failed" + + nginxModuleLoadEnabled = "enabled" + nginxModuleLoadDisabled = "disabled" + + nginxModuleOperateCreate = "create" + nginxModuleOperateUpdate = "update" + nginxModuleOperateDelete = "delete" + + nginxModuleBuildDir = "build" + nginxModuleModulesDir = "modules" + nginxModuleConfDir = "conf" + nginxModuleTmpDir = "tmp" + nginxModuleEnabledConfDir = "modules-enabled" + nginxModuleStagingDir = ".staging" + nginxModuleLibDir = "lib" + nginxModuleBuilderFile = "Dockerfile.modules" + nginxModuleStoreFile = "module.json" + nginxModuleCatalogFile = "module.catalog.json" + nginxModulePreScriptFile = "module-pre.sh" + nginxModuleConfigArgsFile = "module-config.args" + nginxModuleStaticPreScript = "pre.sh" + nginxModuleManifestFile = "manifest.json" + + nginxModuleContainerRoot = "/usr/local/openresty/nginx/modules/1panel" + nginxModuleConfigPrefix = "1panel-module-" + + nginxModuleStaticBuildHint = "; switch the module to static build mode to compile it with the full image" +) + +var errNginxModuleBuilderMissing = errors.New("dynamic module builder not found") + +type nginxModuleBuildSpec struct { + Install model.AppInstall + Module dto.NginxModule + Target dto.NginxModuleTarget + BuildPath string + Force bool + Task *task.Task +} + +// nginxModuleArtifactProvider keeps local builds replaceable by a future +// precompiled-artifact resolver without changing module state or API contracts. +type nginxModuleArtifactProvider interface { + Name() string + Resolve(spec nginxModuleBuildSpec) (dto.NginxModuleBuild, error) +} + +type localNginxModuleProvider struct{} + +func (localNginxModuleProvider) Name() string { + return nginxModuleProviderLocal +} + +func resolveNginxModuleTarget(install model.AppInstall) (dto.NginxModuleTarget, string, error) { + builderPath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleBuilderFile) + builderContent, err := os.ReadFile(builderPath) + if err != nil { + return dto.NginxModuleTarget{}, "", fmt.Errorf("%w: %v", errNginxModuleBuilderMissing, err) + } + builderSum := sha256.Sum256(builderContent) + target := dto.NginxModuleTarget{ + OpenRestyVersion: install.Version, + Architecture: runtime.GOARCH, + BuilderDigest: hex.EncodeToString(builderSum[:]), + } + envContent, _ := os.ReadFile(install.GetEnvPath()) + images, imageErr := dockerUtils.GetImagesFromDockerCompose(envContent, []byte(install.DockerCompose)) + if imageErr != nil || len(images) == 0 { + images, imageErr = compose.GetComposeImages(install.GetComposePath()) + } + var warning string + if imageErr == nil && len(images) > 0 { + target.Image = images[0] + inspectMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute)) + inspectOut, inspectErr := inspectMgr.RunWithStdout("docker", "image", "inspect", "--format={{.Id}}\t{{.Architecture}}", target.Image) + if inspectErr != nil { + warning = fmt.Sprintf("inspect OpenResty image %s failed, module rebuilds will fall back to host architecture %s: %v", target.Image, target.Architecture, inspectErr) + } else if fields := strings.Fields(inspectOut); len(fields) >= 2 { + target.ImageDigest = fields[0] + target.Architecture = fields[1] + } + } + keyInput := strings.Join([]string{target.OpenRestyVersion, target.Architecture, target.ImageDigest, target.BuilderDigest}, "\x00") + keySum := sha256.Sum256([]byte(keyInput)) + target.Key = fmt.Sprintf("%s-%s-%s", sanitizeModulePathPart(target.OpenRestyVersion), target.Architecture, hex.EncodeToString(keySum[:6])) + return target, warning, nil +} + +// nginxModuleDynamicSupported reports whether the installed version ships both +// the dynamic module builder and the module catalog. +func nginxModuleDynamicSupported(install model.AppInstall) bool { + fileOp := files.NewFileOp() + buildPath := path.Join(install.GetPath(), nginxModuleBuildDir) + return fileOp.Stat(path.Join(buildPath, nginxModuleBuilderFile)) && + fileOp.Stat(path.Join(buildPath, nginxModuleCatalogFile)) +} + +func buildDynamicNginxModules(install model.AppInstall, modules []dto.NginxModule, selected []string, force bool, parentTask *task.Task) ([]dto.NginxModule, error) { + // Skip target resolution entirely when nothing needs a dynamic build, so + // installs without dynamic modules do not require Dockerfile.modules. + if !hasDynamicNginxModuleBuildTask(modules, selected) { + return modules, nil + } + target, targetWarning, err := resolveNginxModuleTarget(install) + if err != nil { + if !errors.Is(err, errNginxModuleBuilderMissing) { + return modules, err + } + // The new app version ships no dynamic builder: degrade to inactive + // modules instead of failing the surrounding install or upgrade. + degraded := fmt.Sprintf("dynamic module builder missing, enabled dynamic modules stay inactive: %v", err) + if parentTask != nil { + parentTask.Logf("WARNING: %s", degraded) + } else { + global.LOG.Warn(degraded) + } + for i := range modules { + module := &modules[i] + normalizeNginxModule(module) + if module.Deleted || !module.Enable || module.BuildMode == nginxModuleBuildStatic { + continue + } + module.LastError = "dynamic module builder (Dockerfile.modules) not found for target version; module kept inactive" + } + return modules, nil + } + if targetWarning != "" { + parentTask.Logf("WARNING: %s", targetWarning) + } + originalModules := cloneNginxModules(modules) + selectedNames := make(map[string]struct{}, len(selected)) + for _, name := range selected { + selectedNames[name] = struct{}{} + } + buildPath := path.Join(install.GetPath(), nginxModuleBuildDir) + for i := range modules { + module := &modules[i] + normalizeNginxModule(module) + if !nginxModuleNeedsDynamicBuild(*module, selectedNames) { + continue + } + provider, providerErr := getNginxModuleProvider(module.Provider) + if providerErr != nil { + return modules, providerErr + } + previousBuild := findCurrentNginxModuleBuild(*module, target) + build, buildErr := provider.Resolve(nginxModuleBuildSpec{ + Install: install, Module: *module, Target: target, BuildPath: buildPath, Force: force, Task: parentTask, + }) + if buildErr != nil { + build.Provider = provider.Name() + build.Status = nginxModuleStatusFailed + build.Target = target + build.Error = buildErr.Error() + build.BuiltAt = time.Now() + failedModules := recordNginxModuleBuildFailure(originalModules, module.Name, build, previousBuild, false) + removeNginxModuleOutputsNotReferenced(install, modules, failedModules) + _ = saveNginxModules(install, failedModules) + return failedModules, fmt.Errorf("build dynamic module %s: %w%s", module.Name, buildErr, nginxModuleStaticBuildHint) + } + module.DynamicSupport = nginxModuleSupportSupported + err = validateNginxModuleArtifacts(install, build.Artifacts) + if err != nil { + for outputDir := range nginxModuleOutputDirectories(install, []dto.NginxModule{{Builds: []dto.NginxModuleBuild{build}}}) { + _ = os.RemoveAll(outputDir) + } + build.Status = nginxModuleStatusFailed + build.Error = err.Error() + build.Artifacts = nil + build.BuiltAt = time.Now() + failedModules := recordNginxModuleBuildFailure(originalModules, module.Name, build, previousBuild, true) + removeNginxModuleOutputsNotReferenced(install, modules, failedModules) + _ = saveNginxModules(install, failedModules) + return failedModules, fmt.Errorf("validate dynamic module %s: %w%s", module.Name, err, nginxModuleStaticBuildHint) + } + module.LastError = "" + upsertNginxModuleBuild(module, build) + } + return modules, nil +} + +func (localNginxModuleProvider) Resolve(spec nginxModuleBuildSpec) (dto.NginxModuleBuild, error) { + params, err := normalizeDynamicModuleParams(spec.Module.Params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + configureArgs, err := parseDynamicModuleParams(params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + if err = validateNginxModulePackages(spec.Module.Packages); err != nil { + return dto.NginxModuleBuild{}, err + } + buildHash, err := nginxModuleBuildHash(spec.Module, spec.Target, params) + if err != nil { + return dto.NginxModuleBuild{}, err + } + result := dto.NginxModuleBuild{ + Provider: nginxModuleProviderLocal, + Status: nginxModuleStatusPending, + Hash: buildHash, + Target: spec.Target, + } + modulesRoot := path.Join(spec.Install.GetPath(), nginxModuleModulesDir) + if !spec.Force { + if current := findCurrentNginxModuleBuild(spec.Module, spec.Target); current != nil && current.Status == nginxModuleStatusReady { + if validateNginxModuleArtifacts(spec.Install, current.Artifacts) == nil { + return *current, nil + } + } + } + outputRevision := buildHash + if spec.Force { + outputRevision = fmt.Sprintf("%s-r%d", buildHash, time.Now().UnixNano()) + } + modulePathName := nginxModulePathName(spec.Module.Name) + finalPath := path.Join(modulesRoot, spec.Target.Key, modulePathName, outputRevision) + buildComplete := false + defer func() { + if !buildComplete { + _ = os.RemoveAll(finalPath) + } + }() + + preScriptPath := path.Join(spec.BuildPath, nginxModuleTmpDir, nginxModulePreScriptFile) + if err = os.WriteFile(preScriptPath, []byte("#!/bin/bash\nset -e\n"+spec.Module.Script+"\n"), constant.FilePerm); err != nil { + return result, err + } + defer os.Remove(preScriptPath) + configureArgsPath := path.Join(spec.BuildPath, nginxModuleTmpDir, nginxModuleConfigArgsFile) + if err = os.WriteFile(configureArgsPath, []byte(strings.Join(configureArgs, "\n")+"\n"), constant.FilePerm); err != nil { + return result, err + } + defer os.Remove(configureArgsPath) + + shortHash := buildHash + if len(shortHash) > 12 { + shortHash = shortHash[:12] + } + taskSuffix := sanitizeModulePathPart(spec.Task.TaskID) + if len(taskSuffix) > 8 { + taskSuffix = taskSuffix[:8] + } + tempImage := fmt.Sprintf("1panel/openresty-module-builder:%s-%s-%s", modulePathName, shortHash, taskSuffix) + tempContainer := fmt.Sprintf("1panel-module-%s-%s-%s", modulePathName, shortHash, taskSuffix) + commandMgr := cmd.NewCommandMgr(cmd.WithTask(*spec.Task), cmd.WithTimeout(120*time.Minute)) + buildArgs := []string{ + "build", "--target", "module-output", + "-f", path.Join(spec.BuildPath, nginxModuleBuilderFile), + "-t", tempImage, + "--build-arg", "PANEL_OPENRESTY_VERSION=" + spec.Install.Version, + "--build-arg", "RESTY_ADD_PACKAGE_BUILDDEPS=" + strings.Join(spec.Module.Packages, " "), + spec.BuildPath, + } + if err = commandMgr.Run("docker", buildArgs...); err != nil { + return result, err + } + cleanupMgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute)) + defer func() { + _ = cleanupMgr.Run("docker", "rm", "-f", tempContainer) + _ = cleanupMgr.Run("docker", "image", "rm", "-f", tempImage) + }() + if err = commandMgr.Run("docker", "create", "--name", tempContainer, tempImage, "/bin/true"); err != nil { + return result, err + } + + stagingPath := path.Join(spec.Install.GetPath(), nginxModuleModulesDir, nginxModuleStagingDir, modulePathName+"-"+shortHash) + _ = os.RemoveAll(stagingPath) + if err = os.MkdirAll(stagingPath, constant.DirPerm); err != nil { + return result, err + } + defer os.RemoveAll(stagingPath) + if err = commandMgr.Run("docker", "cp", tempContainer+":/out/.", stagingPath); err != nil { + return result, err + } + if artifacts, artifactErr := collectNginxModuleArtifacts(stagingPath, stagingPath); artifactErr != nil || len(artifacts) == 0 { + if artifactErr != nil { + return result, artifactErr + } + return result, errors.New("dynamic module build produced no loadable .so files") + } + if err = os.MkdirAll(path.Dir(finalPath), constant.DirPerm); err != nil { + return result, err + } + _ = os.RemoveAll(finalPath) + if err = os.Rename(stagingPath, finalPath); err != nil { + return result, err + } + artifacts, err := collectNginxModuleArtifacts(finalPath, modulesRoot) + if err != nil { + return result, err + } + result.Status = nginxModuleStatusReady + result.Artifacts = artifacts + result.BuiltAt = time.Now() + manifest, _ := json.MarshalIndent(result, "", " ") + _ = os.WriteFile(path.Join(finalPath, nginxModuleManifestFile), manifest, constant.FilePerm) + buildComplete = true + return result, nil +} + +func commitNginxModuleBuilds(install model.AppInstall, previousModules, modules []dto.NginxModule, reload bool) error { + if err := saveNginxModules(install, modules); err != nil { + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return err + } + if err := reconcileDynamicNginxModuleConfig(install, modules, reload); err != nil { + rollbackModules := recordNginxModuleActivationFailure(previousModules, modules, err) + _ = saveNginxModules(install, rollbackModules) + removeNginxModuleOutputsNotReferenced(install, modules, previousModules) + return err + } + removeNginxModuleOutputsNotReferenced(install, previousModules, modules) + return nil +} + +func validateNginxModuleArtifacts(install model.AppInstall, artifacts []dto.NginxModuleArtifact) error { + if len(artifacts) == 0 { + return errors.New("module build produced no loadable artifacts") + } + modulesRoot := filepath.Clean(path.Join(install.GetPath(), nginxModuleModulesDir)) + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + if _, ok := seen[artifact.Path]; ok { + return fmt.Errorf("duplicate module artifact path %q", artifact.Path) + } + seen[artifact.Path] = struct{}{} + if artifact.Name != path.Base(artifact.Path) { + return fmt.Errorf("module artifact name %q does not match path %q", artifact.Name, artifact.Path) + } + if !re.IsValidNginxModuleChecksum(artifact.Checksum) { + return fmt.Errorf("invalid checksum for module artifact %q", artifact.Path) + } + if !re.IsValidNginxModuleArtifact(artifact.Path) { + return fmt.Errorf("invalid module artifact path %q", artifact.Path) + } + artifactFullPath := filepath.Clean(filepath.Join(modulesRoot, filepath.FromSlash(artifact.Path))) + if !strings.HasPrefix(artifactFullPath, modulesRoot+string(os.PathSeparator)) { + return fmt.Errorf("module artifact path %q escapes the module directory", artifact.Path) + } + info, err := os.Lstat(artifactFullPath) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("module artifact %q is not a regular file", artifact.Path) + } + file, err := os.Open(artifactFullPath) + if err != nil { + return err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + actualChecksum := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actualChecksum, artifact.Checksum) { + return fmt.Errorf("checksum mismatch for module artifact %q", artifact.Path) + } + } + return nil +} + +func collectNginxModuleArtifacts(root, relativeRoot string) ([]dto.NginxModuleArtifact, error) { + var artifacts []dto.NginxModuleArtifact + err := filepath.Walk(root, func(filePath string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || filepath.Ext(info.Name()) != ".so" || filepath.Base(filepath.Dir(filePath)) == nginxModuleLibDir { + return nil + } + file, err := os.Open(filePath) + if err != nil { + return err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + _ = file.Close() + if copyErr != nil { + return copyErr + } + relativePath, err := filepath.Rel(relativeRoot, filePath) + if err != nil { + return err + } + artifacts = append(artifacts, dto.NginxModuleArtifact{ + Name: info.Name(), Path: filepath.ToSlash(relativePath), Checksum: hex.EncodeToString(hash.Sum(nil)), + }) + return nil + }) + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) + return artifacts, err +} + +func validateNginxModuleLoadConfig(install model.AppInstall, target dto.NginxModuleTarget, validationName, loadDirectives string) error { + if target.Image == "" { + return errors.New("target OpenResty image is not resolved") + } + var config strings.Builder + config.WriteString(loadDirectives) + config.WriteString("events {}\nhttp {}\n") + configSum := sha256.Sum256([]byte(config.String())) + testConfig := path.Join(install.GetPath(), nginxModuleModulesDir, ".test-"+nginxModulePathName(validationName)+"-"+hex.EncodeToString(configSum[:6])+".conf") + if err := os.WriteFile(testConfig, []byte(config.String()), constant.FilePerm); err != nil { + return err + } + defer os.Remove(testConfig) + modulesRoot := path.Join(install.GetPath(), nginxModuleModulesDir) + args := []string{ + "run", "--rm", "--network", "none", + "-v", modulesRoot + ":" + nginxModuleContainerRoot + ":ro", + "-v", testConfig + ":/tmp/1panel-module-test.conf:ro", + "--entrypoint", "/usr/local/openresty/nginx/sbin/nginx", + target.Image, "-t", "-c", "/tmp/1panel-module-test.conf", + } + return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run("docker", args...) +} + +type nginxModuleConfigSnapshot map[string][]byte + +func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.NginxModule, reload bool) error { + target, targetWarning, err := resolveNginxModuleTarget(install) + if err != nil { + if !errors.Is(err, errNginxModuleBuilderMissing) { + return err + } + // Without the builder every dynamic module stays inactive: reconcile + // towards an empty desired state instead of failing the caller. + global.LOG.Warn(err.Error()) + } else if targetWarning != "" { + global.LOG.Warn(targetWarning) + } + configDir := path.Join(install.GetPath(), nginxModuleConfDir, nginxModuleEnabledConfDir) + if err = os.MkdirAll(configDir, constant.DirPerm); err != nil { + return err + } + snapshot, err := snapshotManagedNginxModuleConfigs(configDir) + if err != nil { + return err + } + desired := make(map[string][]byte) + var combinedLoadDirectives strings.Builder + sortedModules := append([]dto.NginxModule(nil), modules...) + sort.SliceStable(sortedModules, func(i, j int) bool { + if sortedModules[i].LoadOrder == sortedModules[j].LoadOrder { + return sortedModules[i].Name < sortedModules[j].Name + } + return sortedModules[i].LoadOrder < sortedModules[j].LoadOrder + }) + for _, module := range sortedModules { + normalizeNginxModule(&module) + if module.Deleted || !module.Enable || module.BuildMode == nginxModuleBuildStatic { + continue + } + build := findCurrentNginxModuleBuild(module, target) + if build == nil || build.Status != nginxModuleStatusReady { + build = findLatestNginxModuleBuild(module, target) + } + if build == nil || build.Status != nginxModuleStatusReady { + continue + } + // Artifacts were already validated after the build; re-validating here + // is a deliberate guard against on-disk tampering before writing + // load_module directives. + if err = validateNginxModuleArtifacts(install, build.Artifacts); err != nil { + return fmt.Errorf("validate artifacts for dynamic module %s: %w", module.Name, err) + } + var content strings.Builder + content.WriteString("# Managed by 1Panel. Manual changes will be overwritten.\n") + for _, artifact := range build.Artifacts { + content.WriteString("load_module ") + content.WriteString(path.Join(nginxModuleContainerRoot, artifact.Path)) + content.WriteString(";\n") + combinedLoadDirectives.WriteString("load_module ") + combinedLoadDirectives.WriteString(path.Join(nginxModuleContainerRoot, artifact.Path)) + combinedLoadDirectives.WriteString(";\n") + } + fileName := fmt.Sprintf("%s%04d-%s.conf", nginxModuleConfigPrefix, module.LoadOrder, nginxModulePathName(module.Name)) + desired[fileName] = []byte(content.String()) + } + if combinedLoadDirectives.Len() > 0 { + if err = validateNginxModuleLoadConfig(install, target, "combined", combinedLoadDirectives.String()); err != nil { + return fmt.Errorf("validate combined dynamic module configuration: %w", err) + } + } + if err = applyManagedNginxModuleConfigs(configDir, desired); err != nil { + _ = applyManagedNginxModuleConfigs(configDir, snapshot) + return err + } + if !reload { + return nil + } + status, statusErr := checkContainerStatus(install.ContainerName) + if statusErr != nil || status != "running" { + return nil + } + if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { + _ = applyManagedNginxModuleConfigs(configDir, snapshot) + return err + } + if err = opNginx(install.ContainerName, constant.NginxReload); err != nil { + _ = applyManagedNginxModuleConfigs(configDir, snapshot) + return err + } + return nil +} + +func snapshotManagedNginxModuleConfigs(configDir string) (nginxModuleConfigSnapshot, error) { + snapshot := make(nginxModuleConfigSnapshot) + entries, err := os.ReadDir(configDir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), nginxModuleConfigPrefix) { + continue + } + content, readErr := os.ReadFile(path.Join(configDir, entry.Name())) + if readErr != nil { + return nil, readErr + } + snapshot[entry.Name()] = content + } + return snapshot, nil +} + +func applyManagedNginxModuleConfigs(configDir string, desired map[string][]byte) error { + entries, err := os.ReadDir(configDir) + if err != nil { + return err + } + for fileName, content := range desired { + tmpPath := path.Join(configDir, "."+fileName+".tmp") + if err = os.WriteFile(tmpPath, content, 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(), nginxModuleConfigPrefix) { + 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 +} + +func hasEnabledStaticNginxModules(modules []dto.NginxModule) bool { + for _, module := range modules { + normalizeNginxModule(&module) + if !module.Deleted && module.Enable && module.BuildMode == nginxModuleBuildStatic { + return true + } + } + 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 + preScriptPath := path.Join(buildPath, nginxModuleTmpDir, nginxModuleStaticPreScript) + preScript, err := os.OpenFile(preScriptPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.FilePerm) + if err != nil { + return err + } + for _, module := range modules { + normalizeNginxModule(&module) + if module.Deleted || !module.Enable || module.BuildMode != nginxModuleBuildStatic { + continue + } + if _, err = preScript.WriteString(module.Script + "\n"); err != nil { + _ = preScript.Close() + return err + } + params = append(params, module.Params) + packages = append(packages, module.Packages...) + } + if err = preScript.Close(); err != nil { + return err + } + envs, err := gotenv.Read(install.GetEnvPath()) + if err != nil { + return err + } + if mirror != "" { + envs["CONTAINER_PACKAGE_URL"] = mirror + } + envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(compactStrings(params), " ") + envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(compactStrings(packages), " ") + return gotenv.Write(envs, install.GetEnvPath()) +} + +func executeStaticNginxModuleBuild(install model.AppInstall, modules []dto.NginxModule, mirror string, force bool, parentTask *task.Task) error { + if err := configureStaticNginxModules(install, modules, mirror); err != nil { + return err + } + commandMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute)) + if err := commandMgr.Run("docker", "compose", "-f", install.GetComposePath(), "build"); err != nil { + return err + } + previousModules := cloneNginxModules(modules) + // A rebuilt runtime changes the target ABI, so every enabled dynamic module + // must be rebuilt even when the user selected only one module. + modules, err := buildDynamicNginxModules(install, modules, nil, force, parentTask) + if err != nil { + return err + } + if err = commitNginxModuleBuilds(install, previousModules, modules, false); err != nil { + return err + } + _, err = compose.DownAndUp(install.GetComposePath()) + return err +} + +func executeNginxModuleBuild(install model.AppInstall, reqModules []string, force bool, mirror string, parentTask *task.Task, reload bool) error { + modules, err := loadNginxModules(install) + if err != nil { + return err + } + staticBuild := staticNginxBuildRequired(install, modules) + if !staticBuild && hasDynamicNginxModuleBuildTask(modules, reqModules) { + // An explicit build request must fail loudly when the installed version + // does not support dynamic builds; the automatic flows degrade instead. + if !nginxModuleDynamicSupported(install) { + return errors.New("the installed OpenResty version does not support dynamic module builds; use static build mode instead") + } + } + if staticBuild { + return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask) + } + previousModules := cloneNginxModules(modules) + modules, err = buildDynamicNginxModules(install, modules, reqModules, force, parentTask) + if err != nil { + return err + } + return commitNginxModuleBuilds(install, previousModules, modules, reload) +} + +func removeNginxModuleArtifacts(install model.AppInstall, module dto.NginxModule) error { + for _, build := range module.Builds { + moduleDir := path.Join(install.GetPath(), nginxModuleModulesDir, build.Target.Key, nginxModulePathName(module.Name)) + if err := files.NewFileOp().DeleteDir(moduleDir); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func loadNginxModules(install model.AppInstall) ([]dto.NginxModule, error) { + modulePath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile) + modules, err := readNginxModuleFile(modulePath) + if err != nil { + return nil, err + } + catalogPath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile) + catalog, err := readNginxModuleFile(catalogPath) + if err != nil { + return nil, err + } + moduleIndexes := make(map[string]int, len(modules)) + for i := range modules { + moduleIndexes[modules[i].Name] = i + } + for _, catalogModule := range catalog { + if index, ok := moduleIndexes[catalogModule.Name]; ok { + if modules[index].Provider == "" { + modules[index].Provider = catalogModule.Provider + } + if modules[index].DynamicSupport == "" { + modules[index].DynamicSupport = catalogModule.DynamicSupport + } + if modules[index].LoadOrder == 0 { + modules[index].LoadOrder = catalogModule.LoadOrder + } + continue + } + modules = append(modules, catalogModule) + } + for i := range modules { + normalizeNginxModule(&modules[i]) + } + probeNginxModuleDynamicSupport(install, modules) + return modules, nil +} + +// probeNginxModuleDynamicSupport marks the dynamic build capability of each +// dynamic module from its configure params. It only runs when the installed +// version supports dynamic builds; otherwise the marker stays "unknown". +func probeNginxModuleDynamicSupport(install model.AppInstall, modules []dto.NginxModule) { + if !nginxModuleDynamicSupported(install) { + return + } + for i := range modules { + if modules[i].Deleted || modules[i].BuildMode == nginxModuleBuildStatic { + continue + } + if _, err := normalizeDynamicModuleParams(modules[i].Params); err != nil { + modules[i].DynamicSupport = nginxModuleSupportUnsupported + } else { + modules[i].DynamicSupport = nginxModuleSupportSupported + } + } +} + +func readNginxModuleFile(filePath string) ([]dto.NginxModule, error) { + content, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []dto.NginxModule{}, nil + } + return nil, err + } + if len(strings.TrimSpace(string(content))) == 0 { + return []dto.NginxModule{}, nil + } + var modules []dto.NginxModule + if err = json.Unmarshal(content, &modules); err != nil { + return nil, fmt.Errorf("parse OpenResty module configuration %s: %w", filePath, err) + } + return modules, nil +} + +func saveNginxModules(install model.AppInstall, modules []dto.NginxModule) error { + for i := range modules { + normalizeNginxModule(&modules[i]) + } + content, err := json.MarshalIndent(modules, "", " ") + if err != nil { + return err + } + modulePath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile) + if err = os.MkdirAll(path.Dir(modulePath), constant.DirPerm); err != nil { + return err + } + tmpPath := modulePath + ".tmp" + if err = os.WriteFile(tmpPath, content, constant.FilePerm); err != nil { + return err + } + return os.Rename(tmpPath, modulePath) +} + +func normalizeNginxModule(module *dto.NginxModule) { + if module.BuildMode == "" { + // Entries created before the dynamic-module schema must preserve their old behavior. + module.BuildMode = nginxModuleBuildStatic + } + if module.BuildMode == nginxModuleBuildAuto { + // auto is a legacy alias for the dynamic build mode. + module.BuildMode = nginxModuleBuildDynamic + } + if module.Provider == "" { + module.Provider = nginxModuleProviderLocal + } + if module.DynamicSupport == "" { + module.DynamicSupport = nginxModuleSupportUnknown + } + if module.LoadOrder == 0 { + module.LoadOrder = 50 + } + module.Packages = compactStrings(module.Packages) +} + +func compactStrings(items []string) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + +// nginxModuleNeedsDynamicBuild mirrors the dynamic-build filter of +// buildDynamicNginxModules. It normalizes a copy so prescan callers never +// mutate the stored entities. +func nginxModuleNeedsDynamicBuild(module dto.NginxModule, selectedNames map[string]struct{}) bool { + normalizeNginxModule(&module) + if module.Deleted || module.BuildMode == nginxModuleBuildStatic { + return false + } + if len(selectedNames) > 0 { + _, ok := selectedNames[module.Name] + return ok + } + return module.Enable +} + +func hasDynamicNginxModuleBuildTask(modules []dto.NginxModule, selected []string) bool { + selectedNames := make(map[string]struct{}, len(selected)) + for _, name := range selected { + selectedNames[name] = struct{}{} + } + for _, module := range modules { + if nginxModuleNeedsDynamicBuild(module, selectedNames) { + return true + } + } + return false +} + +func findLatestNginxModuleBuild(module dto.NginxModule, target dto.NginxModuleTarget) *dto.NginxModuleBuild { + var latest *dto.NginxModuleBuild + for i := range module.Builds { + if module.Builds[i].Target.Key != target.Key || module.Builds[i].Status != nginxModuleStatusReady { + continue + } + if latest == nil || module.Builds[i].BuiltAt.After(latest.BuiltAt) { + latest = &module.Builds[i] + } + } + return latest +} + +func findCurrentNginxModuleBuild(module dto.NginxModule, target dto.NginxModuleTarget) *dto.NginxModuleBuild { + params, err := normalizeDynamicModuleParams(module.Params) + if err != nil { + return nil + } + buildHash, err := nginxModuleBuildHash(module, target, params) + if err != nil { + return nil + } + for i := range module.Builds { + if module.Builds[i].Target.Key == target.Key && module.Builds[i].Hash == buildHash { + return &module.Builds[i] + } + } + return nil +} + +func upsertNginxModuleBuild(module *dto.NginxModule, build dto.NginxModuleBuild) { + for i := range module.Builds { + if module.Builds[i].Target.Key == build.Target.Key && module.Builds[i].Hash == build.Hash { + module.Builds[i] = build + return + } + } + module.Builds = append(module.Builds, build) +} + +func cloneNginxModules(modules []dto.NginxModule) []dto.NginxModule { + cloned := make([]dto.NginxModule, len(modules)) + copy(cloned, modules) + for i := range cloned { + cloned[i].Packages = append([]string(nil), modules[i].Packages...) + cloned[i].Builds = make([]dto.NginxModuleBuild, len(modules[i].Builds)) + copy(cloned[i].Builds, modules[i].Builds) + for j := range cloned[i].Builds { + cloned[i].Builds[j].Artifacts = append([]dto.NginxModuleArtifact(nil), modules[i].Builds[j].Artifacts...) + } + } + return cloned +} + +func recordNginxModuleBuildFailure(originalModules []dto.NginxModule, moduleName string, build dto.NginxModuleBuild, previousBuild *dto.NginxModuleBuild, dynamicCompileSucceeded bool) []dto.NginxModule { + failedModules := cloneNginxModules(originalModules) + for i := range failedModules { + if failedModules[i].Name != moduleName { + continue + } + failedModules[i].LastError = build.Error + if dynamicCompileSucceeded { + failedModules[i].DynamicSupport = nginxModuleSupportSupported + } + if previousBuild == nil || previousBuild.Status != nginxModuleStatusReady { + upsertNginxModuleBuild(&failedModules[i], build) + } + break + } + return failedModules +} + +func recordNginxModuleActivationFailure(previousModules, candidateModules []dto.NginxModule, activationErr error) []dto.NginxModule { + rollbackModules := cloneNginxModules(previousModules) + candidates := make(map[string]dto.NginxModule, len(candidateModules)) + for _, module := range candidateModules { + candidates[module.Name] = module + } + for i := range rollbackModules { + candidate, ok := candidates[rollbackModules[i].Name] + if !ok || candidate.Deleted || !candidate.Enable || candidate.BuildMode == nginxModuleBuildStatic { + continue + } + rollbackModules[i].LastError = activationErr.Error() + rollbackModules[i].DynamicSupport = candidate.DynamicSupport + } + return rollbackModules +} + +func normalizeDynamicModuleParams(params string) (string, error) { + params = strings.TrimSpace(params) + params = strings.ReplaceAll(params, "--add-module=", "--add-dynamic-module=") + if !strings.Contains(params, "--add-dynamic-module=") && !strings.Contains(params, "=dynamic") { + return "", errors.New("module does not declare a dynamic configure option") + } + if _, err := parseDynamicModuleParams(params); err != nil { + return "", err + } + return params, nil +} + +func parseDynamicModuleParams(params string) ([]string, error) { + // shellwords silently stops at unquoted shell metacharacters instead of + // reporting them, so reject them on the raw input before parsing. + if strings.ContainsAny(params, "\x00\r\n;&|<>") { + return nil, errors.New("dynamic module parameters contain unsupported characters") + } + parser := shellwords.NewParser() + parser.ParseBacktick = false + parser.ParseEnv = false + args, err := parser.Parse(params) + if err != nil { + return nil, fmt.Errorf("parse dynamic module parameters: %w", err) + } + if len(args) == 0 { + return nil, errors.New("dynamic module parameters are empty") + } + for _, arg := range args { + if !strings.HasPrefix(arg, "--") { + return nil, fmt.Errorf("unsupported configure argument %q", arg) + } + if strings.ContainsAny(arg, "\x00\r\n;&|<>") { + return nil, fmt.Errorf("configure argument %q contains unsupported characters", arg) + } + } + return args, nil +} + +func validateNginxModulePackages(packages []string) error { + for _, item := range packages { + if !re.IsValidNginxModulePackage(item) { + return fmt.Errorf("invalid build package %q", item) + } + } + return nil +} + +func nginxModuleBuildHash(module dto.NginxModule, target dto.NginxModuleTarget, params string) (string, error) { + payload := struct { + Name string + Script string + Packages []string + Params string + TargetKey string + Provider string + }{module.Name, module.Script, module.Packages, params, target.Key, module.Provider} + content, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]), nil +} + +func getNginxModuleProvider(name string) (nginxModuleArtifactProvider, error) { + switch name { + case "", nginxModuleProviderLocal: + return localNginxModuleProvider{}, nil + case "prebuilt": + return nil, errors.New("prebuilt module provider is not configured; use the local provider") + default: + return nil, fmt.Errorf("unknown module artifact provider %q", name) + } +} + +func nginxModuleOutputDirectories(install model.AppInstall, modules []dto.NginxModule) map[string]struct{} { + result := make(map[string]struct{}) + modulesRoot := filepath.Clean(path.Join(install.GetPath(), nginxModuleModulesDir)) + for _, module := range modules { + for _, build := range module.Builds { + for _, artifact := range build.Artifacts { + if !re.IsValidNginxModuleArtifact(artifact.Path) { + continue + } + artifactFullPath := filepath.Clean(filepath.Join(modulesRoot, filepath.FromSlash(artifact.Path))) + if !strings.HasPrefix(artifactFullPath, modulesRoot+string(os.PathSeparator)) { + continue + } + outputDir := filepath.Dir(artifactFullPath) + if outputDir != modulesRoot { + result[outputDir] = struct{}{} + } + } + } + } + return result +} + +func removeNginxModuleOutputsNotReferenced(install model.AppInstall, fromModules, referencedModules []dto.NginxModule) { + referenced := nginxModuleOutputDirectories(install, referencedModules) + for outputDir := range nginxModuleOutputDirectories(install, fromModules) { + if _, ok := referenced[outputDir]; !ok { + _ = os.RemoveAll(outputDir) + } + } +} + +func sanitizeModulePathPart(value string) string { + var result strings.Builder + for _, char := range value { + if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '-' || char == '_' { + result.WriteRune(char) + } else { + result.WriteByte('-') + } + } + return strings.Trim(result.String(), "-") +} + +func nginxModulePathName(value string) string { + base := sanitizeModulePathPart(value) + if base == "" { + base = "module" + } + if len(base) > 48 { + base = base[:48] + } + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%s-%s", base, hex.EncodeToString(sum[:4])) +} diff --git a/agent/app/service/nginx_module_test.go b/agent/app/service/nginx_module_test.go new file mode 100644 index 000000000000..467686fa2166 --- /dev/null +++ b/agent/app/service/nginx_module_test.go @@ -0,0 +1,318 @@ +package service + +import ( + "encoding/json" + "errors" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" +) + +func TestNormalizeNginxModulePreservesLegacyStaticMode(t *testing.T) { + module := dto.NginxModule{ + Name: "legacy", + Packages: []string{"git", "", "git", " curl "}, + } + + normalizeNginxModule(&module) + + if module.BuildMode != nginxModuleBuildStatic { + t.Fatalf("expected legacy module to remain static, got %s", module.BuildMode) + } + if module.Provider != nginxModuleProviderLocal { + t.Fatalf("expected local provider, got %s", module.Provider) + } + if len(module.Packages) != 2 || module.Packages[0] != "git" || module.Packages[1] != "curl" { + t.Fatalf("unexpected normalized packages: %#v", module.Packages) + } +} + +func TestNormalizeDynamicModuleParams(t *testing.T) { + params, err := normalizeDynamicModuleParams("--with-http_dav_module --add-module=/tmp/nginx-dav-ext-module") + if err != nil { + t.Fatal(err) + } + expected := "--with-http_dav_module --add-dynamic-module=/tmp/nginx-dav-ext-module" + if params != expected { + t.Fatalf("expected %q, got %q", expected, params) + } + + if _, err = normalizeDynamicModuleParams("--add-module=/tmp/module;touch /tmp/unsafe"); err == nil { + t.Fatal("expected shell metacharacters to be rejected") + } +} + +func TestFindCurrentAndLatestNginxModuleBuild(t *testing.T) { + target := dto.NginxModuleTarget{Key: "target"} + module := dto.NginxModule{ + Name: "example", + Params: "--add-module=/tmp/example", + BuildMode: nginxModuleBuildDynamic, + Provider: nginxModuleProviderLocal, + } + params, err := normalizeDynamicModuleParams(module.Params) + if err != nil { + t.Fatal(err) + } + currentHash, err := nginxModuleBuildHash(module, target, params) + if err != nil { + t.Fatal(err) + } + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + module.Builds = []dto.NginxModuleBuild{ + {Hash: "old", Status: nginxModuleStatusReady, Target: target, BuiltAt: oldTime}, + {Hash: currentHash, Status: nginxModuleStatusReady, Target: target, BuiltAt: newTime}, + } + + if build := findCurrentNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatalf("current build was not selected: %#v", build) + } + if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatalf("latest build was not selected: %#v", build) + } + + module.Params = "--add-module=/tmp/example-v2" + if build := findCurrentNginxModuleBuild(module, target); build != nil { + t.Fatalf("changed module input should be stale, got %#v", build) + } + if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash { + t.Fatal("the previous ready build should remain available until replacement") + } +} + +func TestNginxModulePathNameAvoidsSanitizedNameCollisions(t *testing.T) { + first := nginxModulePathName("example/module") + second := nginxModulePathName("example-module") + if first == second { + t.Fatalf("module path names collided: %s", first) + } + if len(nginxModulePathName(string(make([]byte, 256)))) > 57 { + t.Fatal("module path name should remain safe for Docker resource names") + } +} + +func TestRecordNginxModuleBuildFailureKeepsPreviousReadyBuild(t *testing.T) { + target := dto.NginxModuleTarget{Key: "target"} + ready := dto.NginxModuleBuild{ + Hash: "ready", Status: nginxModuleStatusReady, Target: target, BuiltAt: time.Now().Add(-time.Hour), + } + original := []dto.NginxModule{{ + Name: "example", BuildMode: nginxModuleBuildDynamic, DynamicSupport: nginxModuleSupportUnknown, + Builds: []dto.NginxModuleBuild{ready}, + }} + failed := dto.NginxModuleBuild{ + Hash: "candidate", Status: nginxModuleStatusFailed, Target: target, Error: "load failed", BuiltAt: time.Now(), + } + + result := recordNginxModuleBuildFailure(original, "example", failed, &ready, true) + + if len(result[0].Builds) != 1 || result[0].Builds[0].Hash != "ready" { + t.Fatalf("previous ready build was replaced: %#v", result[0].Builds) + } + if result[0].LastError != failed.Error || result[0].DynamicSupport != nginxModuleSupportSupported { + t.Fatalf("failure metadata was not retained: %#v", result[0]) + } + result[0].Builds[0].Hash = "mutated" + if original[0].Builds[0].Hash != "ready" { + t.Fatal("module clone shares build state with the original") + } +} + +func TestHasDynamicNginxModuleBuildTask(t *testing.T) { + dynamicEnabled := dto.NginxModule{Name: "brotli", Enable: true, BuildMode: nginxModuleBuildDynamic} + staticEnabled := dto.NginxModule{Name: "pagespeed", Enable: true, BuildMode: nginxModuleBuildStatic} + deletedDynamic := dto.NginxModule{Name: "geoip", Enable: true, BuildMode: nginxModuleBuildDynamic, Deleted: true} + disabledDynamic := dto.NginxModule{Name: "waf", Enable: false, BuildMode: nginxModuleBuildDynamic} + + if hasDynamicNginxModuleBuildTask(nil, nil) { + t.Fatal("empty module list should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{staticEnabled}, nil) { + t.Fatal("static-only modules should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{deletedDynamic}, nil) { + t.Fatal("deleted modules should not require a dynamic build") + } + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled}, []string{"other"}) { + t.Fatal("enabled module outside the selection should not require a dynamic build") + } + if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{disabledDynamic}, []string{"waf"}) { + t.Fatal("selected module should require a dynamic build even when disabled") + } + if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled, staticEnabled}, nil) { + t.Fatal("enabled dynamic module should require a dynamic build") + } + + legacy := dto.NginxModule{Name: "legacy", Enable: true} + if hasDynamicNginxModuleBuildTask([]dto.NginxModule{legacy}, nil) { + t.Fatal("legacy module without a build mode stays static and should not require a dynamic build") + } + if legacy.BuildMode != "" { + t.Fatalf("prescan must normalize a copy, got mutated BuildMode %q", legacy.BuildMode) + } +} + +func TestResolveNginxModuleTargetWithoutBuilder(t *testing.T) { + oldDir := global.Dir.AppInstallDir + global.Dir.AppInstallDir = t.TempDir() + t.Cleanup(func() { global.Dir.AppInstallDir = oldDir }) + install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"} + install.App.Key = constant.AppOpenresty + + _, _, err := resolveNginxModuleTarget(install) + if !errors.Is(err, errNginxModuleBuilderMissing) { + t.Fatalf("expected builder-missing sentinel, got %v", err) + } + if !strings.Contains(err.Error(), "dynamic module builder not found") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestMergeOpenrestyModuleVolumes(t *testing.T) { + newService := map[string]interface{}{} + oldService := map[string]interface{}{ + "volumes": []interface{}{ + "./conf:/etc/nginx/conf.d:ro", + "./modules:/usr/local/openresty/nginx/modules/1panel:ro", + "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro", + 12345, + }, + } + + mergeOpenrestyModuleVolumes(newService, oldService) + + merged, ok := newService["volumes"].([]interface{}) + if !ok || len(merged) != 2 { + t.Fatalf("expected two module mounts to be merged, got %#v", newService["volumes"]) + } + for _, volume := range merged { + if volume == "./conf:/etc/nginx/conf.d:ro" { + t.Fatal("unrelated mount should not be merged") + } + } +} + +func TestMergeOpenrestyModuleVolumesKeepsExisting(t *testing.T) { + existing := "./modules:/usr/local/openresty/nginx/modules/1panel:ro" + newService := map[string]interface{}{ + "volumes": []interface{}{existing, map[string]interface{}{"type": "bind"}}, + } + oldService := map[string]interface{}{ + "volumes": []interface{}{ + "./modules:/usr/local/openresty/nginx/modules/1panel:ro", + "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro", + }, + } + + mergeOpenrestyModuleVolumes(newService, oldService) + + merged, ok := newService["volumes"].([]interface{}) + if !ok || len(merged) != 3 { + t.Fatalf("expected only the missing mount to be appended, got %#v", newService["volumes"]) + } + if merged[0] != existing { + t.Fatalf("existing mounts must keep their order, got %#v", merged) + } + if merged[2] != "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro" { + t.Fatalf("missing module mount was not appended, got %#v", merged) + } +} + +func TestNormalizeNginxModuleFoldsAutoIntoDynamic(t *testing.T) { + module := dto.NginxModule{Name: "legacy-auto", BuildMode: nginxModuleBuildAuto} + + normalizeNginxModule(&module) + + if module.BuildMode != nginxModuleBuildDynamic { + t.Fatalf("auto should normalize to dynamic, got %s", module.BuildMode) + } +} + +func writeNginxModuleFixture(t *testing.T, install model.AppInstall, withBuilder bool, modules []dto.NginxModule) { + t.Helper() + buildDir := path.Join(install.GetPath(), nginxModuleBuildDir) + if err := os.MkdirAll(buildDir, constant.DirPerm); err != nil { + t.Fatal(err) + } + if withBuilder { + if err := os.WriteFile(path.Join(buildDir, nginxModuleBuilderFile), []byte("FROM scratch\n"), constant.FilePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path.Join(buildDir, nginxModuleCatalogFile), []byte("[]"), constant.FilePerm); err != nil { + t.Fatal(err) + } + } + content, err := json.Marshal(modules) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(path.Join(buildDir, nginxModuleStoreFile), content, constant.FilePerm); err != nil { + t.Fatal(err) + } +} + +func TestLoadNginxModulesProbesDynamicSupport(t *testing.T) { + oldDir := global.Dir.AppInstallDir + global.Dir.AppInstallDir = t.TempDir() + t.Cleanup(func() { global.Dir.AppInstallDir = oldDir }) + install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"} + install.App.Key = constant.AppOpenresty + writeNginxModuleFixture(t, install, true, []dto.NginxModule{ + {Name: "good", Enable: true, BuildMode: nginxModuleBuildDynamic, Params: "--add-module=/tmp/good"}, + {Name: "bad", Enable: true, BuildMode: nginxModuleBuildDynamic, Params: "--with-nothing"}, + {Name: "meta", Enable: true, BuildMode: nginxModuleBuildDynamic, Params: "--add-module=/tmp/x;touch /tmp/y"}, + {Name: "static-mod", Enable: true, BuildMode: nginxModuleBuildStatic}, + {Name: "deleted", Deleted: true, BuildMode: nginxModuleBuildDynamic, Params: "--with-nothing"}, + }) + + loaded, err := loadNginxModules(install) + if err != nil { + t.Fatal(err) + } + support := make(map[string]string, len(loaded)) + for _, module := range loaded { + support[module.Name] = module.DynamicSupport + } + if support["good"] != nginxModuleSupportSupported { + t.Fatalf("valid dynamic params should probe supported, got %q", support["good"]) + } + if support["bad"] != nginxModuleSupportUnsupported { + t.Fatalf("params without a dynamic option should probe unsupported, got %q", support["bad"]) + } + if support["meta"] != nginxModuleSupportUnsupported { + t.Fatalf("params with shell metacharacters should probe unsupported, got %q", support["meta"]) + } + if support["static-mod"] != nginxModuleSupportUnknown { + t.Fatalf("static module must not be probed, got %q", support["static-mod"]) + } + if support["deleted"] != nginxModuleSupportUnknown { + t.Fatalf("deleted module must not be probed, got %q", support["deleted"]) + } +} + +func TestLoadNginxModulesWithoutBuilderKeepsUnknownSupport(t *testing.T) { + oldDir := global.Dir.AppInstallDir + global.Dir.AppInstallDir = t.TempDir() + t.Cleanup(func() { global.Dir.AppInstallDir = oldDir }) + install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"} + install.App.Key = constant.AppOpenresty + writeNginxModuleFixture(t, install, false, []dto.NginxModule{ + {Name: "good", Enable: true, BuildMode: nginxModuleBuildDynamic, Params: "--add-module=/tmp/good"}, + }) + + loaded, err := loadNginxModules(install) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].DynamicSupport != nginxModuleSupportUnknown { + t.Fatalf("without the builder the support marker must stay unknown, got %#v", loaded) + } +} diff --git a/agent/utils/re/re.go b/agent/utils/re/re.go index e3fb6e93b9b8..157489d70f55 100644 --- a/agent/utils/re/re.go +++ b/agent/utils/re/re.go @@ -48,6 +48,9 @@ const ( SyslogRFC3164Pattern = `^([A-Z][a-z]{2}\s{1,2}\d{1,2}\s\d{2}:\d{2}:\d{2})\s+(.*)$` SyslogRFC3339Pattern = `^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$` SyslogServicePattern = `^(?:\S+\s+)?([[:alnum:]_.@/-]+)(?:\[\d+\])?:\s*(.*)$` + NginxModulePackagePattern = `^[a-zA-Z0-9][a-zA-Z0-9+.-]*$` + NginxModuleArtifactPattern = `^[a-zA-Z0-9_./+-]+\.so$` + NginxModuleChecksumPattern = `^[a-fA-F0-9]{64}$` ) var regexMap = make(map[string]*regexp.Regexp) @@ -96,6 +99,9 @@ func Init() { SyslogRFC3164Pattern, SyslogRFC3339Pattern, SyslogServicePattern, + NginxModulePackagePattern, + NginxModuleArtifactPattern, + NginxModuleChecksumPattern, } for _, pattern := range patterns { @@ -118,3 +124,15 @@ func RegisterRegex(pattern string) { func StripAnsiControlSeq(value string) string { return GetRegex(AnsiControlSeqPattern).ReplaceAllString(value, "") } + +func IsValidNginxModulePackage(value string) bool { + return GetRegex(NginxModulePackagePattern).MatchString(value) +} + +func IsValidNginxModuleArtifact(value string) bool { + return GetRegex(NginxModuleArtifactPattern).MatchString(value) +} + +func IsValidNginxModuleChecksum(value string) bool { + return GetRegex(NginxModuleChecksumPattern).MatchString(value) +} diff --git a/frontend/src/api/interface/nginx.ts b/frontend/src/api/interface/nginx.ts index 0a1b6d48a419..cbe0c80c45b0 100644 --- a/frontend/src/api/interface/nginx.ts +++ b/frontend/src/api/interface/nginx.ts @@ -32,6 +32,14 @@ export namespace Nginx { export interface NginxBuildReq { taskID: string; mirror: string; + modules?: string[]; + force?: boolean; + } + + export interface NginxModuleArtifact { + name: string; + path: string; + checksum: string; } export interface NginxModule { @@ -40,15 +48,33 @@ export namespace Nginx { packages?: string; enable: boolean; params: string; + buildMode: 'auto' | 'dynamic' | 'static'; + provider: 'local' | 'prebuilt'; + dynamicSupport: 'unknown' | 'supported' | 'unsupported'; + loadOrder: number; + buildStatus: 'pending' | 'ready' | 'failed'; + loadStatus: 'enabled' | 'disabled'; + compatibility: 'unknown' | 'compatible' | 'stale' | 'static'; + artifacts?: NginxModuleArtifact[]; + lastError?: string; } export interface NginxBuildConfig { mirror: string; modules: NginxModule[]; + dynamicSupported: boolean; } - export interface NginxModuleUpdate extends NginxModule { + export interface NginxModuleUpdate { operate: string; + name: string; + script?: string; + packages?: string; + enable?: boolean; + params?: string; + buildMode?: 'auto' | 'dynamic' | 'static'; + provider?: 'local' | 'prebuilt'; + loadOrder?: number; } export interface NginxHttpsStatus { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index a05d77c4ed36..4703c2821cb6 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -3751,14 +3751,36 @@ const message = { module: 'Modules', build: 'Build', buildWarn: - 'Building OpenResty requires reserving a certain amount of CPU and memory, which may take a long time, please be patient', + 'Local module builds use CPU and memory. Static modules also rebuild and restart OpenResty. Continue?', + buildPurposeHint: + 'Dynamic modules take effect via hot reload after building (no container restart); including static modules triggers a full image rebuild and container recreation', + buildMode: 'Build mode', + buildModeDynamic: 'Dynamic', + buildModeStatic: 'Static', + buildStatus: 'Build status', + compatibility: 'Compatibility', + loadOrder: 'Load order', + modulesToBuild: 'Dynamic modules (hot-reload after build, no container restart)', + staticModules: 'Static modules', + staticModulesHelper: 'Building will rebuild all modules and restart OpenResty', + forceBuild: 'Rebuild without cache', + buildFailed: 'Last build failed', + pending: 'Pending', + ready: 'Ready', + failed: 'Failed', + unknown: 'Unknown', + compatible: 'Compatible', + stale: 'Rebuild required', + static: 'Static build', + dynamicUnsupported: 'Dynamic build is not supported on the current OpenResty version', + moduleDynamicUnsupported: 'The parameters of this module do not support dynamic build', mirrorUrl: 'Software Source', - paramsHelper: 'For example: --add-module=/tmp/ngx_brotli', + paramsHelper: 'For example: --add-module=/tmp/ngx_brotli; dynamic mode converts it automatically', packagesHelper: 'For example: git, curl (separated by commas)', scriptHelper: 'Scripts to execute before compilation, usually for downloading module source code, installing dependencies, etc.', buildHelper: - 'Click build after adding/modifying a module. OpenResty will automatically restart upon successful build.', + 'Dynamic modules are loaded from configuration after building; static modules still rebuild OpenResty.', defaultHttps: 'HTTPS Anti-tampering', defaultHttpsHelper1: 'Enabling this can resolve HTTPS tampering issues.', sslRejectHandshake: 'Reject default SSL handshake', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 9a9b36eb4461..9c318d7f1757 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -3793,14 +3793,36 @@ const message = { script: 'Scripts', module: 'Módulos', build: 'Compilar', - buildWarn: 'Compilar OpenResty requiere reservar CPU y memoria, puede tomar tiempo, ten paciencia', + buildWarn: 'La compilación local de módulos consume CPU y memoria. Los módulos estáticos también reconstruyen y reinician OpenResty. ¿Continuar?', + buildPurposeHint: + 'Los módulos dinámicos se aplican mediante carga en caliente tras la compilación (sin reiniciar el contenedor); si incluye módulos estáticos, se reconstruirá la imagen completa y se recreará el contenedor', + buildMode: 'Modo de compilación', + buildModeDynamic: 'Dinámico', + buildModeStatic: 'Estático', + buildStatus: 'Estado de compilación', + compatibility: 'Compatibilidad', + loadOrder: 'Orden de carga', + modulesToBuild: 'Módulos dinámicos (carga en caliente tras la compilación, sin reiniciar el contenedor)', + staticModules: 'Módulos estáticos', + staticModulesHelper: 'La compilación reconstruirá todos los módulos y reiniciará OpenResty', + forceBuild: 'Recompilar sin caché', + buildFailed: 'La última compilación falló', + pending: 'Pendiente', + ready: 'Listo', + failed: 'Fallido', + unknown: 'Desconocido', + compatible: 'Compatible', + stale: 'Requiere recompilación', + static: 'Compilación estática', + dynamicUnsupported: 'La compilación dinámica no es compatible con la versión actual de OpenResty', + moduleDynamicUnsupported: 'Los parámetros de este módulo no admiten la compilación dinámica', mirrorUrl: 'Fuente de software', - paramsHelper: 'Por ejemplo: --add-module=/tmp/ngx_brotli', + paramsHelper: 'Por ejemplo: --add-module=/tmp/ngx_brotli; el modo dinámico lo convierte automáticamente', packagesHelper: 'Por ejemplo: git, curl (separados por coma)', scriptHelper: 'Scripts a ejecutar antes de compilar, usualmente para descargar código fuente de módulos, instalar dependencias, etc.', buildHelper: - 'Haz clic en compilar después de agregar/modificar un módulo. OpenResty se reiniciará automáticamente tras una compilación exitosa.', + 'Los módulos dinámicos se cargan desde la configuración tras compilarse; los módulos estáticos siguen reconstruyendo OpenResty.', defaultHttps: 'HTTPS Anti-manipulación', defaultHttpsHelper1: 'Habilitar esto puede resolver problemas de manipulación de HTTPS.', sslRejectHandshake: 'Rechazar handshake SSL predeterminado', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 852b69bb7a4a..58475da42841 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -3725,14 +3725,36 @@ const message = { module: 'ماژول‌ها', build: 'ساخت', buildWarn: - 'ساخت OpenResty نیاز به رزرو مقدار مشخصی از CPU و حافظه دارد که ممکن است زمان‌بر باشد، لطفاً صبور باشید', + 'ساخت محلی ماژول‌ها به CPU و حافظه نیاز دارد. ماژول‌های ایستا OpenResty را دوباره می‌سازند و راه‌اندازی مجدد می‌کنند. ادامه می‌دهید؟', + buildPurposeHint: + 'ماژول‌های پویا پس از ساخت با بارگذاری گرم اعمال می‌شوند (بدون راه‌اندازی مجدد کانتینر)؛ در صورت وجود ماژول ایستا، ایمیج به‌طور کامل بازسازی و کانتینر دوباره ساخته می‌شود', + buildMode: 'حالت ساخت', + buildModeDynamic: 'پویا', + buildModeStatic: 'ایستا', + buildStatus: 'وضعیت ساخت', + compatibility: 'سازگاری', + loadOrder: 'ترتیب بارگذاری', + modulesToBuild: 'ماژول‌های پویا (بارگذاری گرم پس از ساخت، بدون راه‌اندازی مجدد کانتینر)', + staticModules: 'ماژول‌های ایستا', + staticModulesHelper: 'هنگام ساخت، همه ماژول‌ها بازسازی شده و OpenResty راه‌اندازی مجدد می‌شود', + forceBuild: 'ساخت مجدد بدون کش', + buildFailed: 'آخرین ساخت ناموفق بود', + pending: 'در انتظار', + ready: 'آماده', + failed: 'ناموفق', + unknown: 'نامشخص', + compatible: 'سازگار', + stale: 'نیاز به ساخت مجدد', + static: 'ساخت ایستا', + dynamicUnsupported: 'ساخت پویا در نسخه فعلی OpenResty پشتیبانی نمی‌شود', + moduleDynamicUnsupported: 'پارامترهای این ماژول از ساخت پویا پشتیبانی نمی‌کنند', mirrorUrl: 'منبع نرم‌افزار', - paramsHelper: 'مثال: --add-module=/tmp/ngx_brotli', + paramsHelper: 'مثال: --add-module=/tmp/ngx_brotli؛ حالت پویا به‌طور خودکار تبدیل می‌کند', packagesHelper: 'مثال: git, curl (با کاما جدا کنید)', scriptHelper: 'اسکریپت‌هایی که قبل از کامپایل اجرا می‌شوند، معمولاً برای دانلود کد منبع ماژول، نصب وابستگی‌ها و غیره.', buildHelper: - 'پس از افزودن/تغییر ماژول، روی ساخت کلیک کنید. OpenResty در صورت موفقیت آمیز بودن ساخت به طور خودکار راه‌اندازی مجدد می‌شود.', + 'ماژول‌های پویا پس از ساخت از طریق پیکربندی بارگذاری می‌شوند؛ ماژول‌های ایستا همچنان OpenResty را دوباره می‌سازند.', defaultHttps: 'ضد دستکاری HTTPS', defaultHttpsHelper1: 'فعال‌سازی این گزینه می‌تواند مشکلات دستکاری HTTPS را حل کند.', sslRejectHandshake: 'رد دست دادن SSL پیش‌فرض', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 06ebb291f95c..499e330f02b8 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -3773,14 +3773,36 @@ const message = { module: 'モジュール', build: 'ビルド', buildWarn: - 'OpenRestyのビルドには一定量のCPUとメモリを確保する必要があり、時間がかかる場合がありますので、お待ちください。', + 'ローカルでのモジュールビルドは CPU とメモリを使用します。静的モジュールは OpenResty の再ビルドと再起動も行います。続行しますか?', + buildPurposeHint: + '動的モジュールはビルド後にホットリロードで有効になります(コンテナは再起動しません)。静的モジュールが含まれる場合は、イメージが全量再ビルドされコンテナが再作成されます', + buildMode: 'ビルド方式', + buildModeDynamic: '動的', + buildModeStatic: '静的', + buildStatus: 'ビルドステータス', + compatibility: '互換性', + loadOrder: '読み込み順', + modulesToBuild: '動的モジュール(ビルド後ホットリロード、コンテナ再起動なし)', + staticModules: '静的モジュール', + staticModulesHelper: 'ビルド時にすべてのモジュールが再ビルドされ、OpenResty が再起動します', + forceBuild: 'キャッシュを使わず再ビルド', + buildFailed: '前回のビルドに失敗しました', + pending: 'ビルド待ち', + ready: 'ビルド済み', + failed: 'ビルド失敗', + unknown: '不明', + compatible: '互換', + stale: '再ビルドが必要', + static: '静的ビルド', + dynamicUnsupported: '現在の OpenResty バージョンでは動的ビルドはサポートされていません', + moduleDynamicUnsupported: 'このモジュールのパラメータは動的ビルドに対応していません', mirrorUrl: 'ソフトウェアソース', - paramsHelper: '例:--add-module=/tmp/ngx_brotli', + paramsHelper: '例:--add-module=/tmp/ngx_brotli(動的モードでは自動的に変換されます)', packagesHelper: '例:git,curl カンマ区切り', scriptHelper: 'コンパイル前に実行するスクリプト、通常はモジュールソースコードのダウンロード、依存関係のインストールなど', buildHelper: - 'モジュールの追加/変更後にビルドをクリックします。ビルドが成功すると、OpenRestyは自動的に再起動します。', + '動的モジュールはビルド後に設定から読み込まれます。静的モジュールは引き続き OpenResty を再ビルドします。', defaultHttps: 'HTTPS 改ざん防止', defaultHttpsHelper1: 'これを有効にすると、HTTPS 改ざん問題を解決できます。', sslRejectHandshake: 'デフォルト SSL ハンドシェイクを拒否', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index d532a1f50592..5e70faed607f 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -3693,12 +3693,34 @@ const message = { script: '스크립트', module: '모듈', build: '빌드', - buildWarn: 'OpenResty 빌드는 CPU와 메모리의 일정량을 예약해야 하며, 시간이 오래 걸릴 수 있으니 기다려 주세요.', + buildWarn: '로컬 모듈 빌드는 CPU와 메모리를 사용합니다. 정적 모듈은 OpenResty를 다시 빌드하고 재시작합니다. 계속하시겠습니까?', + buildPurposeHint: + '동적 모듈은 빌드 후 핫 리로드로 적용됩니다(컨테이너 재시작 없음). 정적 모듈이 포함되면 이미지 전체가 재빌드되고 컨테이너가 재생성됩니다', + buildMode: '빌드 방식', + buildModeDynamic: '동적', + buildModeStatic: '정적', + buildStatus: '빌드 상태', + compatibility: '호환성', + loadOrder: '로드 순서', + modulesToBuild: '동적 모듈 (빌드 후 핫 리로드, 컨테이너 재시작 없음)', + staticModules: '정적 모듈', + staticModulesHelper: '빌드 시 모든 모듈이 재빌드되고 OpenResty가 재시작됩니다', + forceBuild: '캐시 없이 다시 빌드', + buildFailed: '마지막 빌드 실패', + pending: '대기 중', + ready: '준비됨', + failed: '실패', + unknown: '알 수 없음', + compatible: '호환됨', + stale: '다시 빌드 필요', + static: '정적 빌드', + dynamicUnsupported: '현재 OpenResty 버전에서는 동적 빌드를 지원하지 않습니다', + moduleDynamicUnsupported: '이 모듈의 파라미터는 동적 빌드를 지원하지 않습니다', mirrorUrl: '소프트웨어 소스', - paramsHelper: '예: --add-module=/tmp/ngx_brotli', + paramsHelper: '예: --add-module=/tmp/ngx_brotli; 동적 모드에서는 자동으로 변환됩니다', packagesHelper: '예: git,curl 쉼표로 구분', scriptHelper: '컴파일 전에 실행할 스크립트, 일반적으로 모듈 소스 코드 다운로드, 종속성 설치 등', - buildHelper: '모듈 추가/수정 후 빌드를 클릭하세요. 빌드가 성공하면 OpenResty가 자동으로 재시작됩니다.', + buildHelper: '동적 모듈은 빌드 후 설정을 통해 로드되며, 정적 모듈은 여전히 OpenResty를 다시 빌드합니다.', defaultHttps: 'HTTPS 변조 방지', defaultHttpsHelper1: '이를 활성화하면 HTTPS 변조 문제를 해결할 수 있습니다.', sslRejectHandshake: '기본 SSL 핸드셰이크 거부', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 1b64bcccfb26..659d6d6b3a6f 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -3824,14 +3824,36 @@ const message = { module: 'Modul', build: 'Bina', buildWarn: - 'Membina OpenResty memerlukan menyediakan sejumlah CPU dan memori, dan prosesnya mengambil masa yang lama, sila bersabar.', + 'Binaan modul setempat menggunakan CPU dan memori. Modul statik turut membina semula dan memulakan semula OpenResty. Teruskan?', + buildPurposeHint: + 'Modul dinamik berkuat kuasa melalui muatan semula panas selepas dibina (kontena tidak dimulakan semula); jika modul statik disertakan, imej akan dibina semula sepenuhnya dan kontena dicipta semula', + buildMode: 'Mod binaan', + buildModeDynamic: 'Dinamik', + buildModeStatic: 'Statik', + buildStatus: 'Status binaan', + compatibility: 'Keserasian', + loadOrder: 'Turutan muatan', + modulesToBuild: 'Modul dinamik (muatan semula panas selepas binaan, kontena tidak dimulakan semula)', + staticModules: 'Modul statik', + staticModulesHelper: 'Semasa membina, semua modul akan dibina semula dan OpenResty akan dimulakan semula', + forceBuild: 'Bina semula tanpa cache', + buildFailed: 'Binaan terakhir gagal', + pending: 'Menunggu', + ready: 'Sedia', + failed: 'Gagal', + unknown: 'Tidak diketahui', + compatible: 'Serasi', + stale: 'Perlu dibina semula', + static: 'Binaan statik', + dynamicUnsupported: 'Binaan dinamik tidak disokong pada versi OpenResty semasa', + moduleDynamicUnsupported: 'Parameter modul ini tidak menyokong binaan dinamik', mirrorUrl: 'Sumber Perisian', - paramsHelper: 'Contoh: --add-module=/tmp/ngx_brotli', + paramsHelper: 'Contoh: --add-module=/tmp/ngx_brotli; mod dinamik menukarkannya secara automatik', packagesHelper: 'Contoh: git,curl dipisahkan oleh koma', scriptHelper: 'Skrip yang dilaksanakan sebelum penyusunan, biasanya untuk memuat turun sumber kod modul, memasang kebergantungan, dll.', buildHelper: - 'Klik Bina selepas menambah/mengubah suai modul. Pembinaan yang berjaya akan memulakan semula OpenResty secara automatik.', + 'Modul dinamik dimuatkan melalui konfigurasi selepas dibina; modul statik tetap membina semula OpenResty.', defaultHttps: 'HTTPS Anti-tampering', defaultHttpsHelper1: 'Mengaktifkan ini dapat menyelesaikan masalah tampering HTTPS.', sslRejectHandshake: 'Tolak jabat tangan SSL lalai', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 3419cb23c2d4..aa2a8363f8db 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -3960,14 +3960,36 @@ const message = { module: 'Módulo', build: 'Construir', buildWarn: - 'Construir OpenResty requer a reserva de certa quantidade de CPU e memória, e o processo pode ser demorado, por favor, seja paciente.', + 'A compilação local de módulos consome CPU e memória. Módulos estáticos também recompilam e reiniciam o OpenResty. Continuar?', + buildPurposeHint: + 'Os módulos dinâmicos entram em vigor via hot reload após a compilação (sem reiniciar o contêiner); se houver módulos estáticos, a imagem será totalmente reconstruída e o contêiner recriado', + buildMode: 'Modo de compilação', + buildModeDynamic: 'Dinâmico', + buildModeStatic: 'Estático', + buildStatus: 'Status da compilação', + compatibility: 'Compatibilidade', + loadOrder: 'Ordem de carregamento', + modulesToBuild: 'Módulos dinâmicos (hot reload após a compilação, sem reiniciar o contêiner)', + staticModules: 'Módulos estáticos', + staticModulesHelper: 'A compilação reconstruirá todos os módulos e reiniciará o OpenResty', + forceBuild: 'Recompilar sem cache', + buildFailed: 'A última compilação falhou', + pending: 'Pendente', + ready: 'Pronto', + failed: 'Falhou', + unknown: 'Desconhecido', + compatible: 'Compatível', + stale: 'Requer recompilação', + static: 'Compilação estática', + dynamicUnsupported: 'A compilação dinâmica não é suportada na versão atual do OpenResty', + moduleDynamicUnsupported: 'Os parâmetros deste módulo não suportam compilação dinâmica', mirrorUrl: 'Fonte de Software', - paramsHelper: 'Por exemplo: --add-module=/tmp/ngx_brotli', + paramsHelper: 'Por exemplo: --add-module=/tmp/ngx_brotli; o modo dinâmico converte automaticamente', packagesHelper: 'Por exemplo: git,curl separados por vírgulas', scriptHelper: 'Script a ser executado antes da compilação, geralmente para baixar o código-fonte do módulo, instalar dependências, etc.', buildHelper: - 'Clique em Construir após adicionar/modificar um módulo. Construção bem-sucedida reiniciará automaticamente o OpenResty.', + 'Módulos dinâmicos são carregados pela configuração após a compilação; módulos estáticos ainda recompilam o OpenResty.', defaultHttps: 'HTTPS Anti-tampering', defaultHttpsHelper1: 'A ativação desta opção pode resolver problemas de adulteração HTTPS.', sslRejectHandshake: 'Rejeitar handshake SSL padrão', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index f8023e84350d..3c32ebf26c6c 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -3816,14 +3816,36 @@ const message = { module: 'Модуль', build: 'Сборка', buildWarn: - 'Сборка OpenResty требует резервирования определенного количества CPU и памяти, процесс может занять много времени, пожалуйста, подождите.', + 'Локальная сборка модулей использует CPU и память. Статические модули также пересобирают и перезапускают OpenResty. Продолжить?', + buildPurposeHint: + 'Динамические модули применяются горячей перезагрузкой после сборки (без перезапуска контейнера); при наличии статических модулей образ будет полностью пересобран, а контейнер пересоздан', + buildMode: 'Режим сборки', + buildModeDynamic: 'Динамическая', + buildModeStatic: 'Статическая', + buildStatus: 'Статус сборки', + compatibility: 'Совместимость', + loadOrder: 'Порядок загрузки', + modulesToBuild: 'Динамические модули (горячая перезагрузка после сборки, без перезапуска контейнера)', + staticModules: 'Статические модули', + staticModulesHelper: 'При сборке все модули будут пересобраны, а OpenResty перезапущен', + forceBuild: 'Пересобрать без кэша', + buildFailed: 'Последняя сборка не удалась', + pending: 'Ожидание', + ready: 'Готово', + failed: 'Ошибка', + unknown: 'Неизвестно', + compatible: 'Совместим', + stale: 'Требуется пересборка', + static: 'Статическая сборка', + dynamicUnsupported: 'Динамическая сборка не поддерживается текущей версией OpenResty', + moduleDynamicUnsupported: 'Параметры этого модуля не поддерживают динамическую сборку', mirrorUrl: 'Источник программного обеспечения', - paramsHelper: 'Например: --add-module=/tmp/ngx_brotli', + paramsHelper: 'Например: --add-module=/tmp/ngx_brotli; динамический режим преобразует автоматически', packagesHelper: 'Например: git,curl разделенные запятыми', scriptHelper: 'Скрипт, выполняемый перед компиляцией, обычно для загрузки исходного кода модуля, установки зависимостей и т.д.', buildHelper: - 'Нажмите Сборка после добавления/изменения модуля. Успешная сборка автоматически перезапустит OpenResty.', + 'Динамические модули загружаются из конфигурации после сборки; статические модули по-прежнему пересобирают OpenResty.', defaultHttps: 'HTTPS Анти-вмешательство', defaultHttpsHelper1: 'Включение этого параметра может решить проблему вмешательства в HTTPS.', sslRejectHandshake: 'Отклонить стандартное SSL-рукопожатие', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 6a8091db39f9..0e87d92df52a 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -3816,14 +3816,36 @@ const message = { module: 'Modüller', build: 'Oluştur', buildWarn: - 'OpenResty’nin oluşturulması belirli miktarda CPU ve bellek ayırmayı gerektirir, bu uzun sürebilir, lütfen sabırlı olun', + 'Yerel modül derlemeleri CPU ve bellek kullanır. Statik modüller OpenResty’yi yeniden derleyip yeniden başlatır. Devam edilsin mi?', + buildPurposeHint: + 'Dinamik modüller derleme sonrası sıcak yeniden yükleme ile etkinleşir (konteyner yeniden başlatılmaz); statik modül varsa imaj tamamen yeniden derlenir ve konteyner yeniden oluşturulur', + buildMode: 'Derleme modu', + buildModeDynamic: 'Dinamik', + buildModeStatic: 'Statik', + buildStatus: 'Derleme durumu', + compatibility: 'Uyumluluk', + loadOrder: 'Yükleme sırası', + modulesToBuild: 'Dinamik modüller (derleme sonrası sıcak yeniden yükleme, konteyner yeniden başlatılmaz)', + staticModules: 'Statik modüller', + staticModulesHelper: 'Derleme sırasında tüm modüller yeniden derlenir ve OpenResty yeniden başlatılır', + forceBuild: 'Önbelleksiz yeniden derle', + buildFailed: 'Son derleme başarısız', + pending: 'Beklemede', + ready: 'Hazır', + failed: 'Başarısız', + unknown: 'Bilinmiyor', + compatible: 'Uyumlu', + stale: 'Yeniden derleme gerekli', + static: 'Statik derleme', + dynamicUnsupported: 'Mevcut OpenResty sürümünde dinamik derleme desteklenmiyor', + moduleDynamicUnsupported: 'Bu modülün parametreleri dinamik derlemeyi desteklemiyor', mirrorUrl: 'Yazılım Kaynağı', - paramsHelper: 'Örnek: --add-module=/tmp/ngx_brotli', + paramsHelper: 'Örnek: --add-module=/tmp/ngx_brotli; dinamik mod otomatik olarak dönüştürür', packagesHelper: 'Örnek: git, curl (virgülle ayrılmış)', scriptHelper: 'Derlemeden önce çalıştırılacak betikler, genellikle modül kaynak kodunu indirmek, bağımlılıkları kurmak vb. için', buildHelper: - 'Modül ekledikten/düzenledikten sonra oluştur’a tıklayın. OpenResty, başarılı oluşturma üzerine otomatik olarak yeniden başlatılacaktır.', + 'Dinamik modüller derlemeden sonra yapılandırmadan yüklenir; statik modüller yine OpenResty’yi yeniden derler.', defaultHttps: 'HTTPS Anti-sızdırma', defaultHttpsHelper1: 'Bu özelliği etkinleştirerek HTTPS sızdırma sorunlarını çözebilirsiniz.', sslRejectHandshake: 'Varsayılan SSL el sıkışmasını reddet', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index defcbb8d5a5f..fbfb190bb1d3 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -3485,12 +3485,33 @@ const message = { script: '腳本', module: '模組', build: '建構', - buildWarn: '建構 OpenResty 需要預留一定的 CPU 和記憶體,時間較長,請耐心等待', + buildWarn: '本地建構模組需要佔用一定的 CPU 和記憶體,靜態模組還會重建並重啟 OpenResty,是否繼續?', + buildPurposeHint: '動態模組構建後熱載入生效(容器不重新啟動);包含靜態模組時將全量重建映像檔並重建容器', + buildMode: '構建方式', + buildModeDynamic: '動態模組', + buildModeStatic: '靜態模組', + buildStatus: '構建狀態', + compatibility: '相容性', + loadOrder: '載入順序', + modulesToBuild: '動態模組(構建後熱載入,容器不重新啟動)', + staticModules: '靜態模組', + staticModulesHelper: '構建時將全量重建並重新啟動 OpenResty', + forceBuild: '忽略快取重新構建', + buildFailed: '上次構建失敗', + pending: '待構建', + ready: '已構建', + failed: '構建失敗', + unknown: '未知', + compatible: '相容', + stale: '需要重新構建', + static: '靜態編譯', + dynamicUnsupported: '目前 OpenResty 版本不支援動態構建', + moduleDynamicUnsupported: '此模組參數不支援動態構建', mirrorUrl: '軟體源', - paramsHelper: '例:--add-module=/tmp/ngx_brotli', + paramsHelper: '例:--add-module=/tmp/ngx_brotli,動態模式會自動轉換參數', packagesHelper: '例:git,curl 以逗號分割', scriptHelper: '編譯之前執行的腳本,通常用於下載模組原始碼,安裝依賴等', - buildHelper: '新增/修改模組後點選構建,構建成功後會自動重新啟動 OpenResty', + buildHelper: '動態模組建構後透過設定載入,靜態模組仍會重建 OpenResty。', defaultHttps: 'HTTPS 防竄站', defaultHttpsHelper1: '開啟後可以解決 HTTPS 竄站問題', sslRejectHandshake: '拒絕預設 SSL 握手', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 1e548eb44095..e789e8603b67 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3484,12 +3484,33 @@ const message = { script: '脚本', module: '模块', build: '构建', - buildWarn: '构建 OpenResty 需要预留一定的 CPU 和内存,时间较长,请耐心等待', + buildWarn: '本地构建模块需要占用一定的 CPU 和内存,静态模块还会重建并重启 OpenResty,是否继续?', + buildPurposeHint: '动态模块构建后热加载生效(容器不重启);包含静态模块时将全量重建镜像并重建容器', + buildMode: '构建方式', + buildModeDynamic: '动态模块', + buildModeStatic: '静态模块', + buildStatus: '构建状态', + compatibility: '兼容性', + loadOrder: '加载顺序', + modulesToBuild: '动态模块(构建后热加载,容器不重启)', + staticModules: '静态模块', + staticModulesHelper: '构建时将全量重建并重启 OpenResty', + forceBuild: '忽略缓存重新构建', + buildFailed: '上次构建失败', + pending: '待构建', + ready: '已构建', + failed: '构建失败', + unknown: '未知', + compatible: '兼容', + stale: '需要重新构建', + static: '静态编译', + dynamicUnsupported: '当前 OpenResty 版本不支持动态构建', + moduleDynamicUnsupported: '该模块参数不支持动态构建', mirrorUrl: '软件源', - paramsHelper: '例如:--add-module=/tmp/ngx_brotli', + paramsHelper: '例如:--add-module=/tmp/ngx_brotli,动态模式会自动转换参数', packagesHelper: '例如:git,curl 按,分割', scriptHelper: '编译之前执行的脚本,一般为下载模块源码,安装依赖等', - buildHelper: '添加/修改模块之后点击构建,构建成功后会自动重启 OpenResty', + buildHelper: '动态模块构建后通过配置加载;静态模块仍需重新构建 OpenResty', defaultHttps: 'HTTPS 防窜站', defaultHttpsHelper1: '开启后可以解决 HTTPS 窜站问题', sslRejectHandshake: '拒绝默认 SSL 握手', diff --git a/frontend/src/views/website/website/nginx/module/build/index.vue b/frontend/src/views/website/website/nginx/module/build/index.vue index c470681f4e00..b1fd37ea9944 100644 --- a/frontend/src/views/website/website/nginx/module/build/index.vue +++ b/frontend/src/views/website/website/nginx/module/build/index.vue @@ -1,6 +1,9 @@