Files
OpenFlare/openflare_agent/internal/nginx/manager.go
T

983 lines
29 KiB
Go

package nginx
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"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) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.CombinedOutput()
return output, err
}
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"
)
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 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 {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
}
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()...)
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) CheckHealth(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
return m.Executor.CheckHealth(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) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
if file.Path == "pow_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 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) 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 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())
}