// Package nginx manages OpenResty configuration, runtime, and supporting assets. package nginx import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "log/slog" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "github.com/Rain-kl/Wavelet/pkg/utils" "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" "github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser" ) // RuntimeConfigDirPlaceholder is substituted into generated configs at apply time. const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__" // ResolverDirectivePlaceholder is substituted into generated configs at apply time. const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__" // WAFIPGroupsConfigFileName is the runtime filename for synced WAF IP group data. const WAFIPGroupsConfigFileName = "waf_ip_groups.json" const powConfigFileName = "pow_config.json" const ( nginxConfigFilePerm = 0o644 nginxPrivateKeyFilePerm = 0o600 nginxDirPerm = 0o755 stubStatusCheckTimeout = 1500 * time.Millisecond nginxVersionSubmatchCount = 2 resolverAddressCapacity = 2 ) // Executor controls OpenResty validation, reload, health, and lifecycle operations. type Executor interface { Test(ctx context.Context) error Reload(ctx context.Context) error EnsureRuntime(ctx context.Context, recreate bool) error CheckHealth(ctx context.Context) error Restart(ctx context.Context) error } // CommandRunner executes external commands on behalf of an Executor. type CommandRunner interface { Run(ctx context.Context, name string, args ...string) ([]byte, error) } // OSCommandRunner runs commands using the host operating system. type OSCommandRunner struct{} // Run executes the named command and returns its combined output. func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { slog.Debug("OSCommandRunner starting command", "name", name, "args", args) tmpFile, err := os.CreateTemp("", "openflare-cmd-*") if err != nil { slog.Error("OSCommandRunner failed to create temp file, falling back to CombinedOutput", "error", err) cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // command name and args come from trusted OpenResty management paths output, outErr := cmd.CombinedOutput() slog.Debug("OSCommandRunner finished CombinedOutput", "name", name, "error", outErr) return output, outErr } defer func() { _ = os.Remove(tmpFile.Name()) }() cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // command name and args come from trusted OpenResty management paths cmd.Stdout = tmpFile cmd.Stderr = tmpFile slog.Debug("OSCommandRunner executing cmd.Run()", "name", name) runErr := cmd.Run() slog.Debug("OSCommandRunner cmd.Run() returned", "name", name, "error", runErr) _ = tmpFile.Close() output, _ := os.ReadFile(tmpFile.Name()) slog.Debug("OSCommandRunner command complete", "name", name, "output_len", len(output)) return output, runErr } // PathExecutor runs OpenResty using a configured binary and config path. type PathExecutor struct { Path string ConfigPath string Runner CommandRunner } // Test validates the current OpenResty configuration. func (e *PathExecutor) Test(ctx context.Context) error { slog.Debug("running openresty test with binary", "path", e.Path, "config", e.ConfigPath) output, err := e.Runner.Run(ctx, e.Path, "-t", "-c", e.ConfigPath) if err != nil { return fmt.Errorf("openresty -t failed: %w: %s", err, string(output)) } slog.Debug("openresty test succeeded with binary", "path", e.Path) return nil } // Reload reloads OpenResty or starts it when no runtime process is running. func (e *PathExecutor) Reload(ctx context.Context) error { slog.Debug("running openresty reload with binary", "path", e.Path, "config", e.ConfigPath) output, err := e.Runner.Run(ctx, e.Path, "-s", "reload", "-c", e.ConfigPath) if err != nil { if isOpenrestyNotRunningError(string(output)) { slog.Warn("openresty reload reported runtime is not running, starting binary", "path", e.Path) startOutput, startErr := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath) if startErr != nil { return fmt.Errorf("openresty reload failed: %w: %s; start failed: %v: %s", err, string(output), startErr, string(startOutput)) } return nil } return fmt.Errorf("openresty reload failed: %w: %s", err, string(output)) } slog.Debug("openresty reload succeeded with binary", "path", e.Path) return nil } // EnsureRuntime validates configuration and reloads the OpenResty runtime. func (e *PathExecutor) EnsureRuntime(ctx context.Context, _ bool) error { if err := e.Test(ctx); err != nil { return err } return e.Reload(ctx) } // CheckHealth reports whether the OpenResty configuration is valid. func (e *PathExecutor) CheckHealth(ctx context.Context) error { return e.Test(ctx) } // Restart stops and starts the OpenResty runtime process. func (e *PathExecutor) Restart(ctx context.Context) error { slog.Info("restarting openresty with binary", "path", e.Path, "config", e.ConfigPath) output, err := e.Runner.Run(ctx, e.Path, "-s", "quit", "-c", e.ConfigPath) if err != nil { text := string(output) if !isIgnorableOpenrestyStopError(text) { return fmt.Errorf("openresty stop failed: %w: %s", err, text) } } output, err = e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath) if err != nil { return fmt.Errorf("openresty start failed: %w: %s", err, string(output)) } slog.Info("openresty restart succeeded with binary", "path", e.Path) return nil } // Manager applies OpenResty configuration and manages runtime assets. type Manager struct { MainConfigPath string RouteConfigPath string AccessLogPath string CertDir string NginxCertDir string LuaDir string NginxLuaDir string RuntimeConfigDir string PagesDir string OpenrestyObservabilityListen string OpenrestyObservabilityPort int OpenrestyResolverDirective string Executor Executor } // ApplyStatus reports the outcome of an OpenResty configuration apply. type ApplyStatus string // Apply outcome status values. const ( // ApplyStatusSuccess indicates the configuration was applied successfully. ApplyStatusSuccess ApplyStatus = "success" ApplyStatusWarning ApplyStatus = "warning" ApplyStatusFatal ApplyStatus = "fatal" ) const safeDefaultFallbackMainConfig = `# This file is generated by OpenFlare safe default fallback. user ` + runtimeuser.Name + `; worker_processes auto; pid __OPENFLARE_PID_PATH__; events { worker_connections 1024; } http { default_type text/plain; server_tokens off; client_body_temp_path __OPENFLARE_NGINX_CACHE_DIR__/client_temp; proxy_temp_path __OPENFLARE_NGINX_CACHE_DIR__/proxy_temp; fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp; uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp; scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp; server { listen 80 default_server; server_name _; return 503 "OpenFlare: No Valid Configuration\n"; } %s } ` const safeDefaultFallbackObservabilityServerBlock = ` server { listen %s; server_name openflare-observability; access_log off; location = /openflare/stub_status { stub_status; } } ` // ApplyOutcome contains the status and message from a configuration apply. type ApplyOutcome struct { Status ApplyStatus Message string } type wafIPGroupsRuntimeConfig struct { Groups map[string]protocol.WAFIPGroup `json:"groups"` } // Apply writes, validates, and activates new OpenResty configuration files. func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome { slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles)) backup, err := m.backup() if err != nil { return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err)) } if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil { return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err)) } if err = m.activateConfig(ctx); err != nil { return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err)) } slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) return ApplyOutcome{Status: ApplyStatusSuccess} } func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { if err := m.EnsureLuaAssets(); err != nil { return err } if err := m.writeCertFiles(supportFiles); err != nil { return err } if err := m.writePowConfig(supportFiles); err != nil { return err } if err := m.writeWAFConfig(supportFiles); err != nil { return err } if err := m.writeSourceConfig(supportFiles); err != nil { return err } if err := m.ensureMimeTypes(); err != nil { return err } if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") { slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured") } renderedMainConfig := m.renderMainConfig(mainConfig) if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), nginxConfigFilePerm); err != nil { return err } renderedRouteConfig := m.renderRouteConfig(routeConfig) if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), nginxConfigFilePerm); err != nil { return err } return m.ensureOpenRestyWorkerReadAccess() } // ensureOpenRestyWorkerReadAccess assigns runtime ownership and normalized modes // on agent-managed paths so the agent and OpenResty workers share access. func (m *Manager) ensureOpenRestyWorkerReadAccess() error { targets := []string{ m.RuntimeConfigDir, m.LuaDir, m.PagesDir, filepath.Dir(m.MainConfigPath), filepath.Dir(m.RouteConfigPath), } if m.AccessLogPath != "" { targets = append(targets, filepath.Dir(m.AccessLogPath)) } if pidPath := m.pidRuntimePath(); pidPath != "" { targets = append(targets, filepath.Dir(pidPath)) } if proxyCacheDir := m.proxyCacheRuntimeDir(); proxyCacheDir != "" { targets = append(targets, proxyCacheDir) } seen := make(map[string]struct{}, len(targets)) for _, target := range targets { cleaned := filepath.Clean(strings.TrimSpace(target)) if cleaned == "" || cleaned == "." { continue } if _, ok := seen[cleaned]; ok { continue } seen[cleaned] = struct{}{} if err := runtimeuser.EnsurePathOwnership(cleaned, nginxDirPerm, nginxConfigFilePerm); err != nil { return err } } return nil } func (m *Manager) activateConfig(ctx context.Context) error { if m.Executor == nil { return errors.New("executor 未配置") } if err := m.Executor.Test(ctx); err != nil { return err } return m.Executor.Reload(ctx) } func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome { slog.Warn("openresty apply failed, restoring previous config", "error", applyErr) if err := m.restore(backup); err != nil { return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err)) } if err := m.activateConfig(ctx); err != nil { if backup != nil && backup.MainExisted { return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err)) } if fallbackErr := m.EnsureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil { return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w; fallback recovery failed: %v", applyErr, err, fallbackErr)) } message := fmt.Sprintf("apply failed, but fallback runtime started: %v; rollback recovery failed: %v", applyErr, err) slog.Warn("openresty apply recovered with safe default fallback", "message", message) return ApplyOutcome{ Status: ApplyStatusWarning, Message: message, } } message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr) slog.Warn("openresty apply rolled back successfully", "message", message) return ApplyOutcome{ Status: ApplyStatusWarning, Message: message, } } func fatalApplyOutcome(err error) ApplyOutcome { if err == nil { return ApplyOutcome{Status: ApplyStatusFatal} } return ApplyOutcome{ Status: ApplyStatusFatal, Message: strings.TrimSpace(err.Error()), } } // EnsureLuaAssets synchronizes managed Lua and static assets to the runtime directory. func (m *Manager) EnsureLuaAssets() error { if strings.TrimSpace(m.LuaDir) == "" { return nil } allSupportFiles := append(ManagedObservabilityLuaFiles(), m.managedPowLuaFiles()...) allSupportFiles = append(allSupportFiles, m.managedWAFLuaFiles()...) powStaticFiles, err := ManagedPowStaticFiles() if err != nil { return fmt.Errorf("load pow static files: %w", err) } allSupportFiles = append(allSupportFiles, powStaticFiles...) files := make([]managedFile, 0, len(allSupportFiles)) for _, file := range allSupportFiles { targetPath, err := luaFileTargetPath(m.LuaDir, file.Path) if err != nil { return err } relativePath, err := filepath.Rel(m.LuaDir, targetPath) if err != nil { return err } files = append(files, managedFile{ Path: filepath.ToSlash(relativePath), Content: []byte(file.Content), Mode: nginxConfigFilePerm, }) } return syncManagedFiles(m.LuaDir, files) } // EnsureRuntime validates and reloads the current OpenResty runtime configuration. func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error { if m.Executor == nil { return errors.New("executor 未配置") } slog.Info("openresty ensure runtime requested", "recreate", recreate) return m.Executor.EnsureRuntime(ctx, recreate) } // EnsureSafeFallbackRuntime starts a minimal safe default OpenResty runtime. func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) error { if m.Executor == nil { return errors.New("executor 未配置") } trimmedReason := strings.TrimSpace(reason) if trimmedReason == "" { trimmedReason = "no valid local openresty config is available" } slog.Warn("starting openresty safe default fallback runtime", "reason", trimmedReason) if err := m.writeSafeDefaultFallbackFiles(); err != nil { return fmt.Errorf("write safe default fallback config failed: %w", err) } if err := m.activateConfig(ctx); err != nil { return fmt.Errorf("activate safe default fallback runtime failed: %w", err) } slog.Warn("openresty safe default fallback runtime started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) return nil } // CheckHealth verifies that OpenResty configuration and health endpoints are available. func (m *Manager) CheckHealth(ctx context.Context) error { if m.Executor == nil { return errors.New("executor 未配置") } if m.MainConfigPath != "" { if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) { return errors.New("openresty config not exists: waiting for initial sync") } } if m.OpenrestyObservabilityPort <= 0 { return m.Executor.CheckHealth(ctx) } return m.checkStubStatus(ctx) } // Restart restarts the OpenResty runtime process. func (m *Manager) Restart(ctx context.Context) error { if m.Executor == nil { return errors.New("executor 未配置") } slog.Info("openresty restart requested") return m.Executor.Restart(ctx) } // CurrentChecksum returns a stable checksum for the active OpenResty configuration bundle. func (m *Manager) CurrentChecksum() (string, error) { if m.RouteConfigPath == "" { return "", errors.New("route config path 不能为空") } if m.MainConfigPath == "" { return "", errors.New("main config path 不能为空") } mainData, err := os.ReadFile(m.MainConfigPath) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } data, err := os.ReadFile(m.RouteConfigPath) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } normalizedMain := string(mainData) if includePath := m.routeConfigIncludePath(); includePath != "" { normalizedMain = strings.ReplaceAll(normalizedMain, includePath, openrestyrender.RouteConfigPlaceholder) } if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, openrestyrender.AccessLogPlaceholder) errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log") normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(errorLogPath), openrestyrender.ErrorLogPlaceholder) } if pidPath := m.pidRuntimePath(); pidPath != "" { normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(pidPath), openrestyrender.PIDPathPlaceholder) } if luaDir := m.luaRuntimePath(); luaDir != "" { normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, openrestyrender.LuaDirPlaceholder) } if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder) } if m.OpenrestyObservabilityPort > 0 { normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder) } if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" { normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder) } normalizedRoute := string(data) if m.NginxCertDir != "" { normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder) } if luaDir := m.luaRuntimePath(); luaDir != "" { normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder) normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder) } if pagesDir := m.pagesRuntimePath(); pagesDir != "" { normalizedRoute = strings.ReplaceAll(normalizedRoute, pagesDir, openrestyrender.PagesDirPlaceholder) } files, err := m.readManagedSupportFiles() if err != nil { return "", err } result := bundleChecksum(normalizedMain, normalizedRoute, files) slog.Debug("openresty current checksum calculated", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "checksum", result, "cert_files", len(files)) return result, nil } // WAFIPGroupChecksums returns checksums for locally synced WAF IP groups. func (m *Manager) WAFIPGroupChecksums() (map[string]string, error) { config, err := m.readWAFIPGroupsRuntimeConfig() if err != nil { return nil, err } result := make(map[string]string, len(config.Groups)) for id, group := range config.Groups { if strings.TrimSpace(group.Checksum) != "" { result[id] = strings.TrimSpace(group.Checksum) } } return result, nil } // SyncWAFIPGroups writes WAF IP group definitions to the runtime config directory. func (m *Manager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error { if m.RuntimeConfigDir == "" || len(groups) == 0 { return nil } config, err := m.readWAFIPGroupsRuntimeConfig() if err != nil { return err } if config.Groups == nil { config.Groups = make(map[string]protocol.WAFIPGroup) } for _, group := range groups { if group.ID == 0 { continue } config.Groups[fmt.Sprintf("%d", group.ID)] = group } data, err := json.Marshal(config) if err != nil { return err } if err := os.MkdirAll(m.RuntimeConfigDir, nginxDirPerm); err != nil { return err } path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) if err := os.WriteFile(path, data, nginxConfigFilePerm); err != nil { return fmt.Errorf("write %s: %w", WAFIPGroupsConfigFileName, err) } slog.Info("synced waf ip groups", "path", path, "group_count", len(groups)) return nil } func (m *Manager) readWAFIPGroupsRuntimeConfig() (*wafIPGroupsRuntimeConfig, error) { config := &wafIPGroupsRuntimeConfig{Groups: map[string]protocol.WAFIPGroup{}} if m.RuntimeConfigDir == "" { return config, nil } path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) data, err := os.ReadFile(path) //nolint:gosec // path is under managed RuntimeConfigDir if err != nil { if os.IsNotExist(err) { return config, nil } return nil, err } if len(data) == 0 { return config, nil } if err := json.Unmarshal(data, config); err != nil { return nil, err } if config.Groups == nil { config.Groups = map[string]protocol.WAFIPGroup{} } return config, nil } // ExecutorOptions configures construction of an OpenResty Executor. type ExecutorOptions struct { NginxPath string MainConfigPath string RouteConfigPath string CertDir string NginxCertDir string LuaDir string NginxLuaDir string OpenrestyObservabilityPort int } // NewExecutor creates an Executor backed by a configured OpenResty binary. func NewExecutor(options ExecutorOptions) Executor { runner := &OSCommandRunner{} return &PathExecutor{ Path: strings.TrimSpace(options.NginxPath), ConfigPath: strings.TrimSpace(options.MainConfigPath), Runner: runner, } } // DetectVersion returns the OpenResty version reported by the configured binary. func DetectVersion(ctx context.Context, options ExecutorOptions) string { version, err := detectVersion(ctx, options, &OSCommandRunner{}) if err != nil { slog.Error("detect openresty version failed", "error", err) return "" } slog.Info("detected openresty version", "version", version) return version } func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandRunner) (string, error) { if runner == nil { runner = &OSCommandRunner{} } if options.NginxPath != "" { output, err := runner.Run(ctx, options.NginxPath, "-v") if err != nil { return "", fmt.Errorf("run runtime -v failed: %w: %s", err, string(output)) } version := parseExtVersion(string(output)) if version == "" { return "", errors.New("cannot parse runtime version from binary output") } return version, nil } return "", errors.New("openresty path is empty") } func parseExtVersion(output string) string { matches := nginxVersionPattern.FindStringSubmatch(output) if len(matches) != nginxVersionSubmatchCount { return "" } return matches[1] } var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`) func isIgnorableOpenrestyStopError(output string) bool { text := strings.ToLower(strings.TrimSpace(output)) if text == "" { return false } return strings.Contains(text, "invalid pid") || strings.Contains(text, "no such process") } func isOpenrestyNotRunningError(output string) bool { text := strings.ToLower(strings.TrimSpace(output)) if text == "" { return false } return strings.Contains(text, "invalid pid") || strings.Contains(text, "no such process") || strings.Contains(text, "open()") && strings.Contains(text, "nginx.pid") && strings.Contains(text, "failed") } type backupState struct { MainExisted bool MainData []byte RouteExisted bool RouteData []byte Files []protocol.SupportFile PowConfig *protocol.SupportFile WAFConfig *protocol.SupportFile SourceConfig *protocol.SupportFile } type managedFile struct { Path string Content []byte Mode fs.FileMode } func (m *Manager) backup() (*backupState, error) { if m.MainConfigPath == "" { return nil, errors.New("main config path 不能为空") } if m.RouteConfigPath == "" { return nil, errors.New("route config path 不能为空") } if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { return nil, err } if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), nginxDirPerm); err != nil { return nil, err } if m.AccessLogPath != "" { if err := os.MkdirAll(filepath.Dir(m.AccessLogPath), nginxDirPerm); err != nil { return nil, err } } if m.CertDir != "" { if err := os.MkdirAll(m.CertDir, nginxDirPerm); err != nil { return nil, err } } if m.RuntimeConfigDir != "" { if err := os.MkdirAll(m.RuntimeConfigDir, nginxDirPerm); err != nil { return nil, err } } state := &backupState{} mainData, err := os.ReadFile(m.MainConfigPath) if err == nil { state.MainExisted = true state.MainData = mainData } else if !os.IsNotExist(err) { return nil, err } data, err := os.ReadFile(m.RouteConfigPath) if err == nil { state.RouteExisted = true state.RouteData = data } else if !os.IsNotExist(err) { return nil, err } files, err := m.readCertFiles() if err != nil { return nil, err } state.Files = files powConfig, err := m.readPowConfigFile() if err != nil { return nil, err } state.PowConfig = powConfig wafConfig, err := m.readRuntimeConfigFile("waf_config.json") if err != nil { return nil, err } state.WAFConfig = wafConfig sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName) if err != nil { return nil, err } state.SourceConfig = sourceConfig slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files)) return state, nil } func (m *Manager) restore(state *backupState) error { if state == nil { return nil } slog.Warn("restoring nginx backup", "main_existed", state.MainExisted, "route_existed", state.RouteExisted, "cert_files", len(state.Files)) if state.MainExisted { if err := os.WriteFile(m.MainConfigPath, state.MainData, nginxConfigFilePerm); err != nil { return err } } else if err := os.Remove(m.MainConfigPath); err != nil && !os.IsNotExist(err) { return err } if state.RouteExisted { if err := os.WriteFile(m.RouteConfigPath, state.RouteData, nginxConfigFilePerm); err != nil { return err } } else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) { return err } if m.CertDir != "" { if err := m.writeManagedCertFiles(state.Files); err != nil { return err } } if err := m.restoreRuntimeConfig(state.PowConfig, powConfigFileName); err != nil { return err } if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil { return err } return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName) } func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error { if m.CertDir == "" { return nil } return m.writeManagedCertFiles(certFiles) } // writePowConfig persists legacy pow_config.json for backward compatibility. // PoW runtime loads site config from waf_config.json; pow_config.json is deprecated. func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error { if m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, powConfigFileName) for _, file := range supportFiles { if file.Path == powConfigFileName { if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { return fmt.Errorf("write pow_config.json: %w", err) } slog.Info("wrote pow config", "path", configPath, "size", len(file.Content)) return nil } } if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove pow_config.json: %w", err) } if err := removeLegacyPowConfig(filepath.Join(m.LuaDir, powConfigFileName)); err != nil { return err } if err := removeLegacyPowConfig(filepath.Join(m.CertDir, powConfigFileName)); err != nil { return err } return nil } func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error { if m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, "waf_config.json") for _, file := range supportFiles { if file.Path == "waf_config.json" { if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { return fmt.Errorf("write waf_config.json: %w", err) } slog.Info("wrote waf config", "path", configPath, "size", len(file.Content)) return nil } } if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove waf_config.json: %w", err) } return nil } func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error { if m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName) for _, file := range supportFiles { if file.Path == openrestyrender.SourceConfigFileName { if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err) } slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content)) return nil } } if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, err) } return nil } func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error { files := make([]managedFile, 0, len(certFiles)) for _, file := range certFiles { if file.Path == powConfigFileName || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName { continue } targetPath, err := m.certFileTargetPath(file.Path) if err != nil { return err } relativePath, err := filepath.Rel(m.CertDir, targetPath) if err != nil { return err } files = append(files, managedFile{ Path: filepath.ToSlash(relativePath), Content: []byte(file.Content), Mode: certFileMode(file.Path), }) } return syncManagedFiles(m.CertDir, files) } func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) { if m.CertDir == "" { return nil, nil } if _, err := os.Stat(m.CertDir); err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } files := make([]protocol.SupportFile, 0) err := filepath.Walk(m.CertDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } relativePath, err := filepath.Rel(m.CertDir, path) if err != nil { return err } if filepath.ToSlash(relativePath) == powConfigFileName { return nil } data, err := os.ReadFile(path) //nolint:gosec // path is under managed baseDir walk root if err != nil { return err } files = append(files, protocol.SupportFile{ Path: filepath.ToSlash(relativePath), Content: string(data), }) return nil }) if err != nil { return nil, err } sort.Slice(files, func(i int, j int) bool { return files[i].Path < files[j].Path }) return files, nil } func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) { return m.readRuntimeConfigFile(powConfigFileName) } func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) { if m.RuntimeConfigDir == "" { return nil, nil } configPath := filepath.Join(m.RuntimeConfigDir, name) data, err := os.ReadFile(configPath) //nolint:gosec // configPath is under managed RuntimeConfigDir if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } return &protocol.SupportFile{ Path: name, Content: string(data), }, nil } func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) { files, err := m.readCertFiles() if err != nil { return nil, err } powConfig, err := m.readPowConfigFile() if err != nil { return nil, err } if powConfig != nil { files = append(files, *powConfig) } wafConfig, err := m.readRuntimeConfigFile("waf_config.json") if err != nil { return nil, err } if wafConfig != nil { files = append(files, *wafConfig) } return files, nil } func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error { if m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, name) if file == nil { if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return err } return nil } return os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm) } func (m *Manager) writeSafeDefaultFallbackFiles() error { if strings.TrimSpace(m.MainConfigPath) == "" { return errors.New("main config path 不能为空") } if strings.TrimSpace(m.RouteConfigPath) == "" { return errors.New("route config path 不能为空") } if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { return err } if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), nginxDirPerm); err != nil { return err } if err := os.WriteFile(m.RouteConfigPath, nil, nginxConfigFilePerm); err != nil { return err } if err := os.WriteFile(m.MainConfigPath, []byte(m.renderMainConfig(m.safeDefaultFallbackMainConfig())), nginxConfigFilePerm); err != nil { return err } return nil } func (m *Manager) safeDefaultFallbackMainConfig() string { observabilityBlock := "" if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { observabilityBlock = fmt.Sprintf(safeDefaultFallbackObservabilityServerBlock, listen) } return fmt.Sprintf(safeDefaultFallbackMainConfig, observabilityBlock) } func (m *Manager) checkStubStatus(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, stubStatusCheckTimeout) defer cancel() openrestyStubURL := fmt.Sprintf("http://127.0.0.1:%d/openflare/stub_status", m.OpenrestyObservabilityPort) req, err := http.NewRequestWithContext(ctx, http.MethodGet, openrestyStubURL, nil) if err != nil { return err } resp, err := (&http.Client{}).Do(req) if err != nil { return fmt.Errorf("openresty health endpoint unreachable: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("openresty health endpoint returned %s", resp.Status) } slog.Debug("openresty health endpoint is healthy", "url", openrestyStubURL) return nil } func removeLegacyPowConfig(path string) error { if strings.TrimSpace(path) == "" { return nil } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove legacy pow_config.json %q: %w", path, err) } return nil } func (m *Manager) certFileTargetPath(relativePath string) (string, error) { if strings.TrimSpace(m.CertDir) == "" { return "", errors.New("cert dir 不能为空") } candidate := strings.TrimSpace(relativePath) if strings.Contains(candidate, `\`) { candidate = strings.ReplaceAll(candidate, `\`, "/") } normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) if normalizedPath == "." || normalizedPath == "" { return "", errors.New("cert file path 不能为空") } if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { return "", fmt.Errorf("cert file path %q must be relative", relativePath) } targetPath := filepath.Join(m.CertDir, normalizedPath) relativeToBase, err := filepath.Rel(m.CertDir, targetPath) if err != nil { return "", err } if relativeToBase == ".." || strings.HasPrefix(relativeToBase, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("cert file path %q escapes cert dir", relativePath) } return targetPath, nil } func certFileMode(relativePath string) fs.FileMode { switch strings.ToLower(filepath.Ext(strings.TrimSpace(relativePath))) { case ".crt", ".pem": return nginxConfigFilePerm case ".key": return nginxPrivateKeyFilePerm default: return nginxConfigFilePerm } } func luaFileTargetPath(baseDir string, relativePath string) (string, error) { if strings.TrimSpace(baseDir) == "" { return "", errors.New("lua dir 不能为空") } candidate := strings.TrimSpace(relativePath) if strings.Contains(candidate, `\`) { candidate = strings.ReplaceAll(candidate, `\`, "/") } normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) if normalizedPath == "." || normalizedPath == "" { return "", errors.New("lua file path 不能为空") } if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { return "", fmt.Errorf("lua file path %q must be relative", relativePath) } targetPath := filepath.Join(baseDir, normalizedPath) relativeToBase, err := filepath.Rel(baseDir, targetPath) if err != nil { return "", err } if relativeToBase == ".." || strings.HasPrefix(relativeToBase, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("lua file path %q escapes lua dir", relativePath) } return targetPath, nil } func syncManagedFiles(baseDir string, files []managedFile) error { if strings.TrimSpace(baseDir) == "" { return errors.New("managed dir cannot be empty") } if info, err := os.Stat(baseDir); err == nil && !info.IsDir() { return fmt.Errorf("managed dir %q is not a directory", baseDir) } else if err != nil && !os.IsNotExist(err) { return err } if err := os.MkdirAll(baseDir, nginxDirPerm); err != nil { return err } desired := make(map[string]managedFile, len(files)) for _, file := range files { cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(file.Path))) if cleanPath == "." || cleanPath == "" { return errors.New("managed file path cannot be empty") } desired[cleanPath] = managedFile{ Path: cleanPath, Content: file.Content, Mode: file.Mode, } } if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } relativePath, err := filepath.Rel(baseDir, path) if err != nil { return err } if _, ok := desired[filepath.Clean(relativePath)]; ok { return nil } return os.Remove(path) //nolint:gosec // path is resolved under the managed baseDir walk root }); err != nil { return err } for _, file := range desired { targetPath := filepath.Join(baseDir, file.Path) if err := os.MkdirAll(filepath.Dir(targetPath), nginxDirPerm); err != nil { return err } if err := os.WriteFile(targetPath, file.Content, file.Mode); err != nil { return err } } return removeEmptyManagedDirs(baseDir) } func removeEmptyManagedDirs(baseDir string) error { dirs := make([]string, 0) if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() && path != baseDir { dirs = append(dirs, path) } return nil }); err != nil { return err } sort.Slice(dirs, func(i int, j int) bool { return len(dirs[i]) > len(dirs[j]) }) for _, dir := range dirs { entries, err := os.ReadDir(dir) if err != nil { return err } if len(entries) == 0 { if err := os.Remove(dir); err != nil && !os.IsNotExist(err) { return err } } } return nil } func (m *Manager) ensureMimeTypes() error { if m.MainConfigPath == "" { return nil } configDir := filepath.Dir(m.MainConfigPath) mimeTypesPath := filepath.Join(configDir, "mime.types") if _, err := os.Stat(mimeTypesPath); err == nil { return nil } else if !os.IsNotExist(err) { return err } if err := os.MkdirAll(configDir, nginxDirPerm); err != nil { return err } return os.WriteFile(mimeTypesPath, []byte(DefaultMimeTypes), nginxConfigFilePerm) } func (m *Manager) renderRouteConfig(content string) string { rendered := content if m.NginxCertDir != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir) } if luaDir := m.luaRuntimePath(); luaDir != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir) rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static") } if pagesDir := m.pagesRuntimePath(); pagesDir != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.PagesDirPlaceholder, pagesDir) } return rendered } func (m *Manager) renderMainConfig(content string) string { rendered := content if includePath := m.routeConfigIncludePath(); includePath != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.RouteConfigPlaceholder, includePath) } if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.AccessLogPlaceholder, accessLogPath) errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log") rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorLogPlaceholder, filepath.ToSlash(errorLogPath)) } if pidPath := m.pidRuntimePath(); pidPath != "" { slashPIDPath := filepath.ToSlash(pidPath) rendered = strings.ReplaceAll(rendered, openrestyrender.PIDPathPlaceholder, slashPIDPath) rendered = strings.ReplaceAll(rendered, "pid logs/nginx.pid;", "pid "+slashPIDPath+";") if err := os.MkdirAll(filepath.Dir(pidPath), nginxDirPerm); err != nil { slog.Warn("ensure nginx pid directory failed", "path", filepath.Dir(pidPath), "error", err) } } if cacheDir := m.nginxCacheRuntimeDir(); cacheDir != "" { slashCacheDir := filepath.ToSlash(cacheDir) rendered = strings.ReplaceAll(rendered, openrestyrender.NginxCacheDirPlaceholder, slashCacheDir) if !strings.Contains(rendered, "client_body_temp_path") { writablePaths := fmt.Sprintf( " client_body_temp_path %s/client_temp;\n proxy_temp_path %s/proxy_temp;\n fastcgi_temp_path %s/fastcgi_temp;\n uwsgi_temp_path %s/uwsgi_temp;\n scgi_temp_path %s/scgi_temp;\n", slashCacheDir, slashCacheDir, slashCacheDir, slashCacheDir, slashCacheDir, ) rendered = strings.Replace(rendered, "http {", "http {\n"+writablePaths, 1) } for _, subDir := range []string{"client_temp", "proxy_temp", "fastcgi_temp", "uwsgi_temp", "scgi_temp"} { if err := os.MkdirAll(filepath.Join(cacheDir, subDir), nginxDirPerm); err != nil { slog.Warn("ensure nginx cache directory failed", "path", filepath.Join(cacheDir, subDir), "error", err) } } } if proxyCacheDir := m.proxyCacheRuntimeDir(); proxyCacheDir != "" { slashProxyCache := filepath.ToSlash(proxyCacheDir) rendered = strings.ReplaceAll(rendered, openrestyrender.ProxyCachePathPlaceholder, slashProxyCache) rendered = strings.ReplaceAll(rendered, "/var/cache/openresty", slashProxyCache) if err := os.MkdirAll(proxyCacheDir, nginxDirPerm); err != nil { slog.Warn("ensure proxy cache directory failed", "path", proxyCacheDir, "error", err) } } if luaDir := m.luaRuntimePath(); luaDir != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir) } if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen) } if m.OpenrestyObservabilityPort > 0 { rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort)) } if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" { rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective) } return rendered } func (m *Manager) managedPowLuaFiles() []protocol.SupportFile { files := ManagedPowLuaFiles() runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir)) for index := range files { files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir) } return files } func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile { files := ManagedWAFLuaFiles() runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir)) for index := range files { files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir) } return files } // ObservabilityListenAddress returns the localhost listen address for stub_status. func ObservabilityListenAddress(port int) string { if port <= 0 { return "" } return fmt.Sprintf("127.0.0.1:%d", port) } // ResolverDirective renders the nginx resolver block for runtime upstream lookups. func ResolverDirective(explicitResolvers []string) string { resolvers := resolverAddresses(explicitResolvers) if len(resolvers) == 0 { return "" } return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " ")) } func resolverAddresses(explicitResolvers []string) []string { if resolvers := utils.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 { return resolvers } data, err := os.ReadFile("/etc/resolv.conf") if err != nil { return nil } return parseResolverAddresses(string(data), false) } func parseResolverAddresses(content string, dockerMode bool) []string { lines := strings.Split(content, "\n") resolvers := make([]string, 0, resolverAddressCapacity) seen := make(map[string]struct{}) for _, line := range lines { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 || fields[0] != "nameserver" { continue } addr := strings.TrimSpace(fields[1]) if addr == "" { continue } if dockerMode && !isUsableDockerResolver(addr) { continue } if _, ok := seen[addr]; ok { continue } seen[addr] = struct{}{} resolvers = append(resolvers, addr) } return resolvers } func isUsableDockerResolver(addr string) bool { ip := net.ParseIP(addr) if ip == nil { return false } return !ip.IsLoopback() && !ip.IsUnspecified() } // RequiresRuntimeResolver reports whether originURL needs a runtime DNS resolver. func RequiresRuntimeResolver(originURL string) bool { parsed, err := url.Parse(strings.TrimSpace(originURL)) if err != nil || parsed.Hostname() == "" { return false } return net.ParseIP(parsed.Hostname()) == nil } func (m *Manager) routeConfigIncludePath() string { return strings.TrimSpace(m.RouteConfigPath) } func (m *Manager) accessLogRuntimePath() string { return filepath.ToSlash(strings.TrimSpace(m.AccessLogPath)) } func (m *Manager) varRuntimeDir() string { if accessLogPath := strings.TrimSpace(m.AccessLogPath); accessLogPath != "" { return filepath.Dir(filepath.Dir(filepath.Dir(accessLogPath))) } if mainConfigPath := strings.TrimSpace(m.MainConfigPath); mainConfigPath != "" { return filepath.Clean(filepath.Join(filepath.Dir(mainConfigPath), "..", "..")) } return "" } func (m *Manager) pidRuntimePath() string { if varRoot := m.varRuntimeDir(); varRoot != "" { return filepath.ToSlash(filepath.Join(varRoot, "run", "nginx.pid")) } return "" } func (m *Manager) nginxCacheRuntimeDir() string { if varRoot := m.varRuntimeDir(); varRoot != "" { return filepath.ToSlash(filepath.Join(varRoot, "cache", "nginx")) } return "" } func (m *Manager) proxyCacheRuntimeDir() string { if varRoot := m.varRuntimeDir(); varRoot != "" { return filepath.Join(varRoot, "cache", "openflare_proxy") } return "" } func (m *Manager) luaRuntimePath() string { if strings.TrimSpace(m.NginxLuaDir) == "" { return "" } return filepath.ToSlash(m.NginxLuaDir) } func (m *Manager) pagesRuntimePath() string { return filepath.ToSlash(strings.TrimSpace(m.PagesDir)) } func checksum(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } func bundleChecksum(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) string { files := append([]protocol.SupportFile(nil), supportFiles...) sort.Slice(files, func(i int, j int) bool { return files[i].Path < files[j].Path }) var builder strings.Builder builder.WriteString(mainConfig) builder.WriteString("\n--route-config--\n") builder.WriteString(routeConfig) builder.WriteString("\n--support-files--\n") for _, file := range files { builder.WriteString(file.Path) builder.WriteString("\n") builder.WriteString(file.Content) builder.WriteString("\n") } return checksum(builder.String()) }