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 ( nginxModuleBuildDynamic = "dynamic" nginxModuleBuildStatic = "static" nginxModuleProviderLocal = "local" 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" nginxModuleCatalogPendingFile = "module.catalog.pending.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 Mirror string 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{} type nginxModuleState struct { Name string `json:"name"` Custom bool `json:"custom,omitempty"` Script string `json:"script,omitempty"` Packages []string `json:"packages,omitempty"` Params string `json:"params,omitempty"` Enable bool `json:"enable"` BuildMode string `json:"buildMode,omitempty"` Provider string `json:"provider,omitempty"` LoadOrder int `json:"loadOrder,omitempty"` Builds []dto.NginxModuleBuild `json:"builds,omitempty"` LastError string `json:"lastError,omitempty"` } func (localNginxModuleProvider) Name() string { return nginxModuleProviderLocal } func resolveNginxModuleTarget(install model.AppInstall) (dto.NginxModuleTarget, string, error) { target, warning, err := resolveNginxRuntimeTarget(install) if err != nil { return target, warning, err } builderPath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleBuilderFile) builderContent, err := os.ReadFile(builderPath) if err != nil { return target, warning, fmt.Errorf("%w: %v", errNginxModuleBuilderMissing, err) } builderSum := sha256.Sum256(builderContent) target.BuilderDigest = hex.EncodeToString(builderSum[:]) setNginxModuleTargetKey(&target) return target, warning, nil } func resolveNginxRuntimeTarget(install model.AppInstall) (dto.NginxModuleTarget, string, error) { target := dto.NginxModuleTarget{ OpenRestyVersion: install.Version, Architecture: runtime.GOARCH, } 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] } } setNginxModuleTargetKey(&target) return target, warning, nil } func setNginxModuleTargetKey(target *dto.NginxModuleTarget) { 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])) } // 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 syncNginxModuleBuilder(detailBuildDir, installBuildDir string) error { sourcePath := path.Join(detailBuildDir, nginxModuleBuilderFile) targetPath := path.Join(installBuildDir, nginxModuleBuilderFile) if _, err := os.Stat(sourcePath); err != nil { if os.IsNotExist(err) { if removeErr := os.Remove(targetPath); removeErr != nil && !os.IsNotExist(removeErr) { return removeErr } return nil } return err } return files.NewFileOp().CopyFile(sourcePath, installBuildDir) } // resolveNginxModuleBuildMirror picks the apt mirror for module builds: the // explicit request value wins, otherwise the install's saved // CONTAINER_PACKAGE_URL. func resolveNginxModuleBuildMirror(install model.AppInstall, mirror string) string { if mirror != "" { return mirror } envs, err := gotenv.Read(install.GetEnvPath()) if err != nil { return "" } return strings.TrimSpace(envs["CONTAINER_PACKAGE_URL"]) } func buildDynamicNginxModules(install model.AppInstall, modules []dto.NginxModule, selected []string, force bool, mirror string, catalogPath string, 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 { return modules, err } 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) buildMirror := resolveNginxModuleBuildMirror(install, mirror) 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, Mirror: buildMirror, 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) removeNginxModuleOutputsNotReferenced(install, modules, failedModules) _ = saveNginxModulesWithCatalog(install, failedModules, catalogPath) return failedModules, fmt.Errorf("build dynamic module %s: %w%s", module.Name, buildErr, nginxModuleStaticBuildErrorHint(*module)) } 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) removeNginxModuleOutputsNotReferenced(install, modules, failedModules) _ = saveNginxModulesWithCatalog(install, failedModules, catalogPath) return failedModules, fmt.Errorf("validate dynamic module %s: %w%s", module.Name, err, nginxModuleStaticBuildErrorHint(*module)) } 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, BuildMode: nginxModuleBuildDynamic, 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, " "), } if spec.Mirror != "" { buildArgs = append(buildArgs, "--build-arg", "CONTAINER_PACKAGE_URL="+spec.Mirror) } buildArgs = append(buildArgs, 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, catalogPath string) error { if err := saveNginxModulesWithCatalog(install, modules, catalogPath); err != nil { removeNginxModuleOutputsNotReferenced(install, modules, previousModules) return err } if err := reconcileDynamicNginxModuleConfig(install, modules, reload); err != nil { rollbackModules := recordNginxModuleActivationFailure(previousModules, modules, err) _ = saveNginxModulesWithCatalog(install, rollbackModules, catalogPath) 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 type openrestyUpgradeSnapshot struct { installPath string backupPath string existing map[string]bool } var openrestyUpgradeSnapshotPaths = []string{ nginxModuleBuildDir, nginxModuleModulesDir, "scripts", path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir), path.Join(nginxModuleConfDir, "nginx.conf"), "docker-compose.yml", ".env", } func createOpenrestyUpgradeSnapshot(installPath string) (*openrestyUpgradeSnapshot, error) { backupPath, err := os.MkdirTemp("", "1panel-openresty-upgrade-*") if err != nil { return nil, err } snapshot := &openrestyUpgradeSnapshot{ installPath: installPath, backupPath: backupPath, existing: make(map[string]bool, len(openrestyUpgradeSnapshotPaths)), } for _, relativePath := range openrestyUpgradeSnapshotPaths { sourcePath := path.Join(installPath, relativePath) if _, err = os.Stat(sourcePath); err != nil { if os.IsNotExist(err) { continue } snapshot.Cleanup() return nil, err } snapshot.existing[relativePath] = true if err = copyOpenrestyUpgradeSnapshotEntry(sourcePath, path.Join(backupPath, relativePath)); err != nil { snapshot.Cleanup() return nil, err } } return snapshot, nil } func copyOpenrestyUpgradeSnapshotEntry(sourcePath, targetPath string) error { if err := os.MkdirAll(path.Dir(targetPath), constant.DirPerm); err != nil { return err } info, err := os.Stat(sourcePath) if err != nil { return err } fileOp := files.NewFileOp() if info.IsDir() { return fileOp.CopyDir(sourcePath, path.Dir(targetPath)) } return fileOp.CopyFile(sourcePath, path.Dir(targetPath)) } func (s *openrestyUpgradeSnapshot) Restore() error { for _, relativePath := range openrestyUpgradeSnapshotPaths { targetPath := path.Join(s.installPath, relativePath) if err := os.RemoveAll(targetPath); err != nil { return err } if !s.existing[relativePath] { continue } if err := copyOpenrestyUpgradeSnapshotEntry(path.Join(s.backupPath, relativePath), targetPath); err != nil { return err } } return nil } func (s *openrestyUpgradeSnapshot) Cleanup() { if s != nil && s.backupPath != "" { _ = os.RemoveAll(s.backupPath) } } func reconcileDynamicNginxModuleConfig(install model.AppInstall, modules []dto.NginxModule, reload bool) error { var target dto.NginxModuleTarget if hasDynamicNginxModuleBuildTask(modules, nil) { var targetWarning string var err error target, targetWarning, err = resolveNginxModuleTarget(install) if err != nil { return err } 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.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.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.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, mirror, "", parentTask) if err != nil { return err } if err = commitNginxModuleBuilds(install, previousModules, modules, false, ""); err != nil { return err } if _, err = compose.DownAndUp(install.GetComposePath()); err != nil { return err } return commitStaticNginxModuleBuilds(install, "", parentTask) } 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) { if !nginxModuleDynamicSupported(install) { return errors.New("the installed OpenResty version does not support dynamic module builds") } } if staticBuild { return executeStaticNginxModuleBuild(install, modules, mirror, force, parentTask) } previousModules := cloneNginxModules(modules) modules, err = buildDynamicNginxModules(install, modules, reqModules, force, mirror, "", 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) { return loadNginxModulesWithCatalog(install, "") } func loadNginxModulesWithCatalog(install model.AppInstall, catalogPath string) ([]dto.NginxModule, error) { modulePath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile) states, err := readNginxModuleStateFile(modulePath) if err != nil { return nil, err } if catalogPath == "" { catalogPath = path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile) } modules, err := readNginxModuleFile(catalogPath) if err != nil { return nil, err } moduleIndexes := make(map[string]int, len(modules)) for i := range modules { modules[i].Custom = false modules[i].Enable = false modules[i].Builds = nil modules[i].LastError = "" if _, exists := moduleIndexes[modules[i].Name]; exists { return nil, fmt.Errorf("duplicate OpenResty module catalog name %q", modules[i].Name) } moduleIndexes[modules[i].Name] = i } stateNames := make(map[string]struct{}, len(states)) for _, state := range states { if _, exists := stateNames[state.Name]; exists { return nil, fmt.Errorf("duplicate OpenResty module state name %q", state.Name) } stateNames[state.Name] = struct{}{} if index, ok := moduleIndexes[state.Name]; ok { if state.Custom { return nil, fmt.Errorf("custom OpenResty module %s conflicts with the module catalog", state.Name) } modules[index].Enable = state.Enable modules[index].Builds = state.Builds modules[index].LastError = state.LastError continue } if !state.Custom { return nil, fmt.Errorf("OpenResty module state %s is missing from the module catalog", state.Name) } modules = append(modules, dto.NginxModule{ Name: state.Name, Custom: true, Script: state.Script, Packages: state.Packages, Params: state.Params, Enable: state.Enable, BuildMode: state.BuildMode, Provider: state.Provider, LoadOrder: state.LoadOrder, Builds: state.Builds, LastError: state.LastError, }) } for i := range modules { if err = validateNginxModuleBuildMode(modules[i]); err != nil { return nil, err } normalizeNginxModule(&modules[i]) } return modules, nil } func readNginxModuleStateFile(filePath string) ([]nginxModuleState, error) { content, err := os.ReadFile(filePath) if err != nil { if os.IsNotExist(err) { return []nginxModuleState{}, nil } return nil, err } if len(strings.TrimSpace(string(content))) == 0 { return []nginxModuleState{}, nil } var states []nginxModuleState if err = json.Unmarshal(content, &states); err != nil { return nil, fmt.Errorf("parse OpenResty module state %s: %w", filePath, err) } return states, nil } 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 { return saveNginxModulesWithCatalog(install, modules, "") } func saveNginxModulesWithCatalog(install model.AppInstall, modules []dto.NginxModule, catalogPath string) error { if catalogPath == "" { catalogPath = path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile) } catalog, err := readNginxModuleFile(catalogPath) if err != nil { return err } catalogNames := make(map[string]struct{}, len(catalog)) for _, module := range catalog { if _, exists := catalogNames[module.Name]; exists { return fmt.Errorf("duplicate OpenResty module catalog name %q", module.Name) } catalogNames[module.Name] = struct{}{} } states := make([]nginxModuleState, 0, len(modules)) for i := range modules { module := &modules[i] if err = validateNginxModuleBuildMode(*module); err != nil { return err } normalizeNginxModule(module) if _, builtin := catalogNames[module.Name]; builtin { if module.Custom { return fmt.Errorf("custom OpenResty module %s conflicts with the module catalog", module.Name) } if !module.Enable && len(module.Builds) == 0 && module.LastError == "" { continue } states = append(states, nginxModuleState{ Name: module.Name, Enable: module.Enable, Builds: module.Builds, LastError: module.LastError, }) continue } if !module.Custom { return fmt.Errorf("OpenResty module %s is missing from the module catalog", module.Name) } states = append(states, nginxModuleState{ Name: module.Name, Custom: true, Script: module.Script, Packages: module.Packages, Params: module.Params, Enable: module.Enable, BuildMode: module.BuildMode, Provider: module.Provider, LoadOrder: module.LoadOrder, Builds: module.Builds, LastError: module.LastError, }) } content, err := json.MarshalIndent(states, "", " ") 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 activateNginxModuleCatalog(pendingPath, activePath string) error { return os.Rename(pendingPath, activePath) } func activateNginxModuleCatalogAndCommit(pendingPath, activePath string, commit func() error) error { previous, err := os.ReadFile(activePath) if err != nil { return err } if err = activateNginxModuleCatalog(pendingPath, activePath); err != nil { return err } if err = commit(); err == nil { return nil } if restoreErr := writeNginxModuleCatalog(activePath, previous); restoreErr != nil { return fmt.Errorf("%w; restore previous OpenResty module catalog: %v", err, restoreErr) } return err } func stageNginxModuleCatalog(sourcePath, pendingPath string) error { content, err := os.ReadFile(sourcePath) if err != nil { return err } return writeNginxModuleCatalog(pendingPath, content) } func writeNginxModuleCatalog(targetPath string, content []byte) error { tmpPath := targetPath + ".tmp" defer func() { _ = os.Remove(tmpPath) }() if err := os.WriteFile(tmpPath, content, constant.FilePerm); err != nil { return err } return os.Rename(tmpPath, targetPath) } func normalizeNginxModule(module *dto.NginxModule) { if module.Provider == "" { module.Provider = nginxModuleProviderLocal } module.Packages = compactStrings(module.Packages) } func validateNginxModuleBuildMode(module dto.NginxModule) error { if module.BuildMode != nginxModuleBuildDynamic && module.BuildMode != nginxModuleBuildStatic { return fmt.Errorf("OpenResty module %s has invalid build mode %q", module.Name, module.BuildMode) } return nil } 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 } func nginxModuleStaticBuildErrorHint(module dto.NginxModule) string { if module.Custom { return nginxModuleStaticBuildHint } return "" } // 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.BuildMode != nginxModuleBuildDynamic { 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].BuildMode != module.BuildMode || 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 := nginxModuleBuildParams(module) 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].BuildMode == module.BuildMode && module.Builds[i].Target.Key == target.Key && module.Builds[i].Hash == buildHash { return &module.Builds[i] } } return nil } func nginxModuleBuildParams(module dto.NginxModule) (string, error) { if module.BuildMode == nginxModuleBuildStatic { params := strings.TrimSpace(module.Params) if params == "" { return "", errors.New("static module parameters are empty") } return params, nil } return normalizeDynamicModuleParams(module.Params) } func recordStaticNginxModuleBuilds(modules []dto.NginxModule, target dto.NginxModuleTarget) ([]dto.NginxModule, error) { for i := range modules { module := &modules[i] normalizeNginxModule(module) if !module.Enable || module.BuildMode != nginxModuleBuildStatic { continue } params, err := nginxModuleBuildParams(*module) if err != nil { return modules, err } buildHash, err := nginxModuleBuildHash(*module, target, params) if err != nil { return modules, err } upsertNginxModuleBuild(module, dto.NginxModuleBuild{ Provider: nginxModuleProviderLocal, BuildMode: nginxModuleBuildStatic, Status: nginxModuleStatusReady, Hash: buildHash, Target: target, BuiltAt: time.Now(), }) module.LastError = "" } return modules, nil } func commitStaticNginxModuleBuilds(install model.AppInstall, catalogPath string, parentTask *task.Task) error { modules, err := loadNginxModulesWithCatalog(install, catalogPath) if err != nil || !hasEnabledStaticNginxModules(modules) { return err } status, err := checkContainerStatus(install.ContainerName) if err != nil { return err } if status != "running" { return fmt.Errorf("OpenResty container %s is not running after static module build", install.ContainerName) } if err = opNginx(install.ContainerName, constant.NginxCheck); err != nil { return err } target, targetWarning, err := resolveNginxRuntimeTarget(install) if err != nil { return err } if targetWarning != "" && parentTask != nil { parentTask.Logf("WARNING: %s", targetWarning) } modules, err = recordStaticNginxModuleBuilds(modules, target) if err != nil { return err } return saveNginxModulesWithCatalog(install, modules, catalogPath) } 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) []dto.NginxModule { failedModules := cloneNginxModules(originalModules) for i := range failedModules { if failedModules[i].Name != moduleName { continue } failedModules[i].LastError = build.Error 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.Enable || candidate.BuildMode == nginxModuleBuildStatic { continue } rollbackModules[i].LastError = activationErr.Error() } 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 BuildMode string TargetKey string Provider string }{module.Name, module.Script, module.Packages, params, module.BuildMode, 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])) }