Files
OpenFlare/internal/apps/agent/nginx/manager.go
T
ryan aa4faddade 补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81}
2026-08-16 20:09:24 +08:00

1662 lines
55 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// 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: %w: %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 %w: %w", applyErr, err))
}
if err := m.activateConfig(ctx); err != nil {
if backup != nil && backup.MainExisted {
return fatalApplyOutcome(fmt.Errorf("apply failed: %w; 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: %w; rollback recovery failed: %w; fallback recovery failed: %w", 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, strconv.Itoa(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, strconv.Itoa(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())
}