// 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" "strconv" "strings" "sync" "time" sharedprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "github.com/Rain-kl/Wavelet/pkg/util" "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__" // CountryMMDBPathPlaceholder is substituted with the configured Country database path. const CountryMMDBPathPlaceholder = "__OPENFLARE_COUNTRY_MMDB_PATH__" // CityMMDBPathPlaceholder is substituted with the configured City database path. const CityMMDBPathPlaceholder = "__OPENFLARE_CITY_MMDB_PATH__" // WAFIPGroupsMaxSnapshotBytesPlaceholder is replaced with the shared protocol aggregate snapshot limit. const WAFIPGroupsMaxSnapshotBytesPlaceholder = "__OPENFLARE_WAF_IP_GROUPS_MAX_SNAPSHOT_BYTES__" // 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" // WAFIPGroupsChecksumFileName is atomically published after the IP group JSON snapshot. const WAFIPGroupsChecksumFileName = WAFIPGroupsConfigFileName + ".checksum" const powConfigFileName = "pow_config.json" const ( nginxConfigFilePerm = 0o644 nginxPrivateKeyFilePerm = 0o600 nginxDirPerm = 0o755 stubStatusCheckTimeout = 1500 * time.Millisecond nginxVersionSubmatchCount = 2 resolverAddressCapacity = 2 workerInitSubmatchCount = 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 MMDBPath string CityMMDBPath string PagesDir string OpenrestyObservabilityListen string OpenrestyObservabilityPort int OpenrestyResolverDirective string Executor Executor atomicFileWriter func(path string, data []byte, perm os.FileMode) error wafIPGroupsMu sync.Mutex } // 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...) allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...) 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 != "" { // Longer error-page path must be restored before the cert-dir prefix rewrite. errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath)) normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder) swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder) 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) { m.wafIPGroupsMu.Lock() defer m.wafIPGroupsMu.Unlock() config, err := m.readWAFIPGroupsRuntimeConfig() if err != nil { return nil, err } if err = m.ensureWAFIPGroupsChecksum(); err != nil { return nil, err } result := make(map[string]string, len(config.Groups)) for id, group := range config.Groups { if id != strconv.FormatUint(uint64(group.ID), 10) { continue } if strings.TrimSpace(group.Checksum) != "" { result[id] = strings.TrimSpace(group.Checksum) } } return result, nil } // ReconcileWAFIPGroups atomically replaces the runtime snapshot with exactly the // authoritative target IDs, retaining local definitions that did not change. func (m *Manager) ReconcileWAFIPGroups(targetIDs []uint, changed []protocol.WAFIPGroup) error { if m.RuntimeConfigDir == "" { return nil } m.wafIPGroupsMu.Lock() defer m.wafIPGroupsMu.Unlock() config, err := m.readWAFIPGroupsRuntimeConfig() if err != nil { return err } target := make(map[string]protocol.WAFIPGroup, len(targetIDs)) targetSet := make(map[uint]struct{}, len(targetIDs)) for _, id := range targetIDs { if id == 0 { continue } targetSet[id] = struct{}{} key := strconv.FormatUint(uint64(id), 10) if group, ok := config.Groups[key]; ok && group.ID == id { target[key] = group } } for _, group := range changed { if _, ok := targetSet[group.ID]; !ok { continue } target[strconv.FormatUint(uint64(group.ID), 10)] = group } for _, id := range targetIDs { if id == 0 { continue } if _, ok := target[strconv.FormatUint(uint64(id), 10)]; !ok { return fmt.Errorf("missing referenced WAF IP group %d after synchronization", id) } } return m.publishWAFIPGroups(target) } // UpdateExistingWAFIPGroups applies real-time changes only to groups already // present in the authoritative local snapshot. func (m *Manager) UpdateExistingWAFIPGroups(changed []protocol.WAFIPGroup) error { if m.RuntimeConfigDir == "" || len(changed) == 0 { return nil } m.wafIPGroupsMu.Lock() defer m.wafIPGroupsMu.Unlock() config, err := m.readWAFIPGroupsRuntimeConfig() if err != nil { return err } updated := false for _, group := range changed { key := strconv.FormatUint(uint64(group.ID), 10) if _, ok := config.Groups[key]; !ok || group.ID == 0 { continue } config.Groups[key] = group updated = true } if !updated { return nil } return m.publishWAFIPGroups(config.Groups) } func (m *Manager) publishWAFIPGroups(groups map[string]protocol.WAFIPGroup) error { data, err := sharedprotocol.MarshalWAFIPGroupSnapshot(groups) if err != nil { return err } if len(data) > sharedprotocol.MaxWAFIPGroupSnapshotBytes { return fmt.Errorf("WAF IP group snapshot size %d exceeds maximum %d bytes", len(data), sharedprotocol.MaxWAFIPGroupSnapshotBytes) } if err := os.MkdirAll(m.RuntimeConfigDir, nginxDirPerm); err != nil { return err } path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) if err := m.writeAtomicFile(path, data, nginxConfigFilePerm); err != nil { return fmt.Errorf("write %s: %w", WAFIPGroupsConfigFileName, err) } checksumPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsChecksumFileName) if err := m.writeAtomicFile(checksumPath, []byte(checksum(string(data))+"\n"), nginxConfigFilePerm); err != nil { return fmt.Errorf("write %s: %w", WAFIPGroupsChecksumFileName, err) } slog.Info("synced waf ip groups", "path", path, "group_count", len(groups)) return nil } func (m *Manager) ensureWAFIPGroupsChecksum() error { if m.RuntimeConfigDir == "" { return nil } jsonPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) data, err := os.ReadFile(jsonPath) //nolint:gosec // path is under managed RuntimeConfigDir if err != nil { if os.IsNotExist(err) { return nil } return err } expected := checksum(string(data)) checksumPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsChecksumFileName) current, err := os.ReadFile(checksumPath) //nolint:gosec // path is under managed RuntimeConfigDir if err == nil && strings.TrimSpace(string(current)) == expected { return nil } if err != nil && !os.IsNotExist(err) { return err } return m.writeAtomicFile(checksumPath, []byte(expected+"\n"), nginxConfigFilePerm) } func (m *Manager) writeAtomicFile(path string, data []byte, perm os.FileMode) error { if m.atomicFileWriter != nil { return m.atomicFileWriter(path, data, perm) } return writeAtomicFile(path, data, perm) } func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr error) { tempFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") if err != nil { return err } tempPath := tempFile.Name() closed := false defer func() { if !closed { if closeErr := tempFile.Close(); resultErr == nil && closeErr != nil { resultErr = closeErr } } _ = os.Remove(tempPath) }() if err = tempFile.Chmod(perm); err != nil { return err } if _, err = tempFile.Write(data); err != nil { return err } if err = tempFile.Sync(); err != nil { return err } if err = tempFile.Close(); err != nil { return err } closed = true if err = os.Rename(tempPath, path); err != nil { return err } 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 { certFiles = append(certFiles, ManagedSWLuaFiles()...) 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 } // Agent-shipped SW Lua assets are not part of the rendered bundle, so they // must stay out of the bundle checksum to keep it aligned with the server. swManaged := make(map[string]struct{}, len(ManagedSWLuaFiles())) for _, file := range ManagedSWLuaFiles() { swManaged[file.Path] = struct{}{} } kept := files[:0] for _, file := range files { if _, skip := swManaged[file.Path]; skip { continue } kept = append(kept, file) } files = kept 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) errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath)) rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath) swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir) } 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 strings.Contains(rendered, "lua_shared_dict openflare_waf_config") { rendered = injectWAFWorkerInit(rendered, 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 injectWAFWorkerInit(content string, luaDir string) string { packagePath := fmt.Sprintf(" lua_package_path \"%s/?.lua;%s/?/init.lua;;\";\n", luaDir, luaDir) if !strings.Contains(content, "lua_package_path ") { content = strings.Replace(content, "http {", "http {\n"+packagePath, 1) } initPattern := regexp.MustCompile(`(?m)^[ \t]*init_worker_by_lua_file[ \t]+([^;]+);[ \t]*$`) if match := initPattern.FindStringSubmatch(content); len(match) == workerInitSubmatchCount { block := fmt.Sprintf(" init_worker_by_lua_block {\n require(\"waf.runtime\").init()\n dofile(%q)\n }", strings.TrimSpace(match[1])) return initPattern.ReplaceAllString(content, block) } return strings.Replace(content, "http {", "http {\n init_worker_by_lua_block { require(\"waf.runtime\").init() }", 1) } 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)) countryMMDBPath := filepath.ToSlash(strings.TrimSpace(m.MMDBPath)) if countryMMDBPath == "" { countryMMDBPath = filepath.ToSlash(filepath.Join(runtimeConfigDir, "GeoLite2-Country.mmdb")) } cityMMDBPath := filepath.ToSlash(strings.TrimSpace(m.CityMMDBPath)) if cityMMDBPath == "" { cityMMDBPath = filepath.ToSlash(filepath.Join(runtimeConfigDir, "GeoLite2-City.mmdb")) } for index := range files { files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir) files[index].Content = strings.ReplaceAll(files[index].Content, CountryMMDBPathPlaceholder, countryMMDBPath) files[index].Content = strings.ReplaceAll(files[index].Content, CityMMDBPathPlaceholder, cityMMDBPath) files[index].Content = strings.ReplaceAll(files[index].Content, WAFIPGroupsMaxSnapshotBytesPlaceholder, strconv.Itoa(sharedprotocol.MaxWAFIPGroupSnapshotBytes)) } 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 := util.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()) }