package nginx import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "log/slog" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" "openflare-agent/internal/protocol" ) const CertDirPlaceholder = "__OPENFLARE_CERT_DIR__" const RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__" const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__" const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__" const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__" const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__" const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__" const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__" const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__" 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 } type CommandRunner interface { Run(ctx context.Context, name string, args ...string) ([]byte, error) } type OSCommandRunner struct{} 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...) output, outErr := cmd.CombinedOutput() slog.Debug("OSCommandRunner finished CombinedOutput", "name", name, "error", outErr) return output, outErr } defer os.Remove(tmpFile.Name()) cmd := exec.CommandContext(ctx, name, args...) 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 } type PathExecutor struct { Path string ConfigPath string Runner CommandRunner } 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 } 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 } func (e *PathExecutor) EnsureRuntime(ctx context.Context, recreate bool) error { if err := e.Test(ctx); err != nil { return err } return e.Reload(ctx) } func (e *PathExecutor) CheckHealth(ctx context.Context) error { return e.Test(ctx) } 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 } type Manager struct { MainConfigPath string RouteConfigPath string AccessLogPath string CertDir string NginxCertDir string LuaDir string NginxLuaDir string RuntimeConfigDir string OpenrestyObservabilityListen string OpenrestyObservabilityPort int OpenrestyResolverDirective string Executor Executor } type ApplyStatus string const ( ApplyStatusSuccess ApplyStatus = "success" ApplyStatusWarning ApplyStatus = "warning" ApplyStatusFatal ApplyStatus = "fatal" ) const safeDefaultFallbackMainConfig = `# This file is generated by OpenFlare safe default fallback. worker_processes auto; pid logs/nginx.pid; events { worker_connections 1024; } http { default_type text/plain; 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; } } ` type ApplyOutcome struct { Status ApplyStatus Message string } 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.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), 0o644); err != nil { return err } renderedRouteConfig := m.renderRouteConfig(routeConfig) if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); 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()), } } 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: 0o644, }) } return syncManagedFiles(m.LuaDir, files) } 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) } 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 } 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) } 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) } 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, RouteConfigPlaceholder) } if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder) } if luaDir := m.luaRuntimePath(); luaDir != "" { normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, LuaDirPlaceholder) } if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { normalizedMain = strings.ReplaceAll(normalizedMain, listen, ObservabilityListenPlaceholder) } if m.OpenrestyObservabilityPort > 0 { normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), 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, CertDirPlaceholder) } if luaDir := m.luaRuntimePath(); luaDir != "" { normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder) normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder) } 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 } type ExecutorOptions struct { NginxPath string MainConfigPath string RouteConfigPath string CertDir string NginxCertDir string LuaDir string NginxLuaDir string OpenrestyObservabilityPort int } func NewExecutor(options ExecutorOptions) Executor { runner := &OSCommandRunner{} return &PathExecutor{ Path: strings.TrimSpace(options.NginxPath), ConfigPath: strings.TrimSpace(options.MainConfigPath), Runner: runner, } } 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 := parseNginxVersion(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 parseNginxVersion(output string) string { matches := nginxVersionPattern.FindStringSubmatch(output) if len(matches) != 2 { 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 } 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), 0o755); err != nil { return nil, err } if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil { return nil, err } if m.AccessLogPath != "" { if err := os.MkdirAll(filepath.Dir(m.AccessLogPath), 0o755); err != nil { return nil, err } } if m.CertDir != "" { if err := os.MkdirAll(m.CertDir, 0o755); err != nil { return nil, err } } if m.RuntimeConfigDir != "" { if err := os.MkdirAll(m.RuntimeConfigDir, 0o755); 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 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, 0o644); 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, 0o644); 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 } } return m.restorePowConfig(state) } func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error { if m.CertDir == "" { return nil } return m.writeManagedCertFiles(certFiles) } func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error { if m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json") for _, file := range supportFiles { if file.Path == "pow_config.json" { if err := os.WriteFile(configPath, []byte(file.Content), 0o644); 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, "pow_config.json")); err != nil { return err } if err := removeLegacyPowConfig(filepath.Join(m.CertDir, "pow_config.json")); 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), 0o644); 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) writeManagedCertFiles(certFiles []protocol.SupportFile) error { files := make([]managedFile, 0, len(certFiles)) for _, file := range certFiles { if file.Path == "pow_config.json" || file.Path == "waf_config.json" { 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) == "pow_config.json" { return nil } data, err := os.ReadFile(path) 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) { if m.RuntimeConfigDir == "" { return nil, nil } configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json") data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } return &protocol.SupportFile{ Path: "pow_config.json", 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) } return files, nil } func (m *Manager) restorePowConfig(state *backupState) error { if state == nil || m.RuntimeConfigDir == "" { return nil } configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json") if state.PowConfig == nil { if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return err } return nil } return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644) } 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), 0o755); err != nil { return err } if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil { return err } if err := os.WriteFile(m.RouteConfigPath, nil, 0o644); err != nil { return err } if err := os.WriteFile(m.MainConfigPath, []byte(m.safeDefaultFallbackMainConfig()), 0o644); 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, 1500*time.Millisecond) 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 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 0o644 case ".key": return 0o600 default: return 0o644 } } 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, 0o755); 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) }); err != nil { return err } for _, file := range desired { targetPath := filepath.Join(baseDir, file.Path) if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); 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, 0o755); err != nil { return err } return os.WriteFile(mimeTypesPath, []byte(DefaultMimeTypes), 0o644) } func (m *Manager) renderRouteConfig(content string) string { rendered := content if m.NginxCertDir != "" { rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir) } if luaDir := m.luaRuntimePath(); luaDir != "" { rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir) rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static") } return rendered } func (m *Manager) renderMainConfig(content string) string { rendered := content if includePath := m.routeConfigIncludePath(); includePath != "" { rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath) } if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath) } if luaDir := m.luaRuntimePath(); luaDir != "" { rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir) } if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { rendered = strings.ReplaceAll(rendered, ObservabilityListenPlaceholder, listen) } if m.OpenrestyObservabilityPort > 0 { rendered = strings.ReplaceAll(rendered, 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 } func ObservabilityListenAddress(openrestyPath string, port int) string { if port <= 0 { return "" } return fmt.Sprintf("127.0.0.1:%d", port) } func ResolverDirective(openrestyPath string, explicitResolvers []string) string { resolvers := resolverAddresses(openrestyPath, 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(openrestyPath string, explicitResolvers []string) []string { if resolvers := normalizeResolverAddresses(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, 2) 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() } func normalizeResolverAddresses(values []string) []string { if len(values) == 0 { return nil } resolvers := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { addr := strings.TrimSpace(value) if addr == "" { continue } if _, ok := seen[addr]; ok { continue } seen[addr] = struct{}{} resolvers = append(resolvers, addr) } if len(resolvers) == 0 { return nil } return resolvers } 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) luaRuntimePath() string { if strings.TrimSpace(m.NginxLuaDir) == "" { return "" } return filepath.ToSlash(m.NginxLuaDir) } 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()) }