From 895dec208f0284730be2d0d9d9780d3eef6a13bd Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 21 Jun 2026 14:40:10 +0800 Subject: [PATCH] fix(agent): write nginx pid and temp dirs under data_dir for non-root runtime OpenResty running as openflare can no longer write pid or client/proxy temp paths under the OpenResty install prefix. Templates and apply-time rendering now use __OPENFLARE_PID_PATH__ and __OPENFLARE_NGINX_CACHE_DIR__ under data_dir/var/run and data_dir/var/cache/nginx, with legacy pid path patched at apply. Consolidate runtimeuser path helpers into the main package file so IDEs resolve references across build tags. --- Makefile | 1 - docs/changelog/index.md | 2 + internal/apps/agent/nginx/manager.go | 65 ++++++++++++- internal/apps/agent/nginx/runtime_access.go | 6 +- .../apps/agent/runtimeuser/runtimeuser.go | 83 ++++++++++++++++- .../agent/runtimeuser/runtimeuser_unix.go | 93 ++----------------- internal/model/openflare_option.go | 7 +- pkg/render/openresty/types.go | 9 +- 8 files changed, 169 insertions(+), 97 deletions(-) diff --git a/Makefile b/Makefile index 87eac661..7a4f3c72 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,6 @@ build-backend: build-agent: @echo "==> Building agent version=$(VERSION)..." - bash scripts/fetch-agent-geoip-mmdb.sh go build \ -ldflags "-s -w -X '$(MODULE)/internal/apps/agent/config.Version=$(VERSION)'" \ -o bin/openflare-agent \ diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 43e9a87c..bd97f600 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -22,6 +22,8 @@ sidebar: false ### 修复 +- 修复 Agent 以 `openflare` 非 root 运行时 OpenResty `-t`/reload 失败:nginx `pid` 与 `client_body_temp`/`proxy_temp` 等临时目录改写入 `data_dir/var/run` 与 `data_dir/var/cache/nginx`(`__OPENFLARE_PID_PATH__` / `__OPENFLARE_NGINX_CACHE_DIR__` 占位符),不再使用 OpenResty 安装目录下不可写路径。 + - 修复 OpenResty 响应泄露版本号:默认主配置模板与 safe fallback 模板补充 `server_tokens off;`,隐藏 `Server` 头与错误页中的 nginx/OpenResty 版本信息。 - 修复 Agent 与 OpenResty worker 权限不一致导致 Pages/WAF 等静态资源 Permission denied:引入共享运行时用户 `openflare`(Agent 进程、OpenResty worker、文件属主统一);Docker 入口脚本在启动前修正 volume 属主并降权;本地 systemd 服务以 `openflare` 运行并授予 `CAP_NET_BIND_SERVICE`;`data_dir` 与 `pages_dir` 等路径在同步/Apply 时统一 `chown` 与 `0755/0644` 规范化。 diff --git a/internal/apps/agent/nginx/manager.go b/internal/apps/agent/nginx/manager.go index 0f8f5dcc..10169ed7 100644 --- a/internal/apps/agent/nginx/manager.go +++ b/internal/apps/agent/nginx/manager.go @@ -188,9 +188,9 @@ const ( ) const safeDefaultFallbackMainConfig = `# This file is generated by OpenFlare safe default fallback. -user ` + OpenFlareRuntimeUser + `; +user ` + runtimeuser.Name + `; worker_processes auto; -pid logs/nginx.pid; +pid __OPENFLARE_PID_PATH__; events { worker_connections 1024; @@ -199,6 +199,11 @@ events { 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; @@ -294,6 +299,9 @@ func (m *Manager) ensureOpenRestyWorkerReadAccess() error { if m.AccessLogPath != "" { targets = append(targets, filepath.Dir(m.AccessLogPath)) } + if pidPath := m.pidRuntimePath(); pidPath != "" { + targets = append(targets, filepath.Dir(pidPath)) + } seen := make(map[string]struct{}, len(targets)) for _, target := range targets { cleaned := filepath.Clean(strings.TrimSpace(target)) @@ -474,6 +482,9 @@ func (m *Manager) CurrentChecksum() (string, error) { 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) } @@ -991,7 +1002,7 @@ func (m *Manager) writeSafeDefaultFallbackFiles() error { if err := os.WriteFile(m.RouteConfigPath, nil, nginxConfigFilePerm); err != nil { return err } - if err := os.WriteFile(m.MainConfigPath, []byte(m.safeDefaultFallbackMainConfig()), nginxConfigFilePerm); err != nil { + if err := os.WriteFile(m.MainConfigPath, []byte(m.renderMainConfig(m.safeDefaultFallbackMainConfig())), nginxConfigFilePerm); err != nil { return err } return nil @@ -1228,6 +1239,30 @@ func (m *Manager) renderMainConfig(content string) string { 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 luaDir := m.luaRuntimePath(); luaDir != "" { rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir) } @@ -1339,6 +1374,30 @@ 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) luaRuntimePath() string { if strings.TrimSpace(m.NginxLuaDir) == "" { return "" diff --git a/internal/apps/agent/nginx/runtime_access.go b/internal/apps/agent/nginx/runtime_access.go index 614221d1..39d01c4a 100644 --- a/internal/apps/agent/nginx/runtime_access.go +++ b/internal/apps/agent/nginx/runtime_access.go @@ -1,17 +1,15 @@ package nginx import ( - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" - "github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser" ) // OpenFlareRuntimeUser is the shared OS account for the agent process and // OpenResty worker processes. -const OpenFlareRuntimeUser = openrestyrender.OpenFlareRuntimeUser +const OpenFlareRuntimeUser = runtimeuser.Name // OpenRestyWorkerUser is an alias kept for internal call sites. -const OpenRestyWorkerUser = OpenFlareRuntimeUser +const OpenRestyWorkerUser = runtimeuser.Name // EnsureWorldTraversablePath makes targetDir and its ancestors world-traversable. func EnsureWorldTraversablePath(targetDir string) error { diff --git a/internal/apps/agent/runtimeuser/runtimeuser.go b/internal/apps/agent/runtimeuser/runtimeuser.go index 94260924..10577d9e 100644 --- a/internal/apps/agent/runtimeuser/runtimeuser.go +++ b/internal/apps/agent/runtimeuser/runtimeuser.go @@ -79,7 +79,7 @@ func EnsureProcessUser() error { slog.Warn("agent is not running as runtime user", "expected", Name, "euid", os.Geteuid()) return nil } - if dropErr := dropTo(account); dropErr != nil { + if dropErr := dropToImpl(account); dropErr != nil { return dropErr } slog.Info("agent dropped privileges to runtime user", "user", Name, "uid", account.UID) @@ -108,4 +108,85 @@ func EnsurePathOwnership(root string, dirPerm os.FileMode, filePerm os.FileMode) return lookupErr } return applyOwnershipAndModes(root, account, dirPerm, filePerm) +} + +var dropToImpl = func(account *Account) error { + return fmt.Errorf("drop to runtime user %s is not supported on this platform", account.Name) +} + +func ensureWorldTraversablePath(targetDir string) error { + const maxDepth = 12 + current := filepath.Clean(strings.TrimSpace(targetDir)) + if current == "" || current == "." { + return nil + } + for depth := 0; depth < maxDepth; depth++ { + if err := os.Chmod(current, DefaultDirPerm); err != nil { //nolint:gosec // parent dirs must be traversable by the runtime user + if os.IsNotExist(err) || os.IsPermission(err) { + break + } + return fmt.Errorf("chmod %s: %w", current, err) + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return nil +} + +func applyOwnershipAndModes(root string, account *Account, dirPerm os.FileMode, filePerm os.FileMode) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + if os.Geteuid() == 0 { + if chownErr := os.Chown(path, account.UID, account.GID); chownErr != nil && !os.IsNotExist(chownErr) { //nolint:gosec // path is under managed root walk + return fmt.Errorf("chown %s: %w", path, chownErr) + } + } + if entry.IsDir() { + if chmodErr := os.Chmod(path, dirPerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk + return fmt.Errorf("chmod dir %s: %w", path, chmodErr) + } + return nil + } + if !info.Mode().IsRegular() { + return nil + } + if chmodErr := os.Chmod(path, filePerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk + return fmt.Errorf("chmod file %s: %w", path, chmodErr) + } + return nil + }) +} + +func ensureModesOnly(root string, dirPerm os.FileMode, filePerm os.FileMode) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + if chmodErr := os.Chmod(path, dirPerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk + return fmt.Errorf("chmod dir %s: %w", path, chmodErr) + } + return nil + } + if !info.Mode().IsRegular() { + return nil + } + if chmodErr := os.Chmod(path, filePerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk + return fmt.Errorf("chmod file %s: %w", path, chmodErr) + } + return nil + }) } \ No newline at end of file diff --git a/internal/apps/agent/runtimeuser/runtimeuser_unix.go b/internal/apps/agent/runtimeuser/runtimeuser_unix.go index 6bac9c89..e73df306 100644 --- a/internal/apps/agent/runtimeuser/runtimeuser_unix.go +++ b/internal/apps/agent/runtimeuser/runtimeuser_unix.go @@ -4,96 +4,17 @@ package runtimeuser import ( "fmt" - "os" - "path/filepath" - "strings" "syscall" ) -func dropTo(account *Account) error { - if err := syscall.Setgid(account.GID); err != nil { - return fmt.Errorf("setgid %d: %w", account.GID, err) - } - if err := syscall.Setuid(account.UID); err != nil { - return fmt.Errorf("setuid %d: %w", account.UID, err) - } - return nil -} - -func ensureWorldTraversablePath(targetDir string) error { - const maxDepth = 12 - current := filepath.Clean(strings.TrimSpace(targetDir)) - if current == "" || current == "." { - return nil - } - for depth := 0; depth < maxDepth; depth++ { - if err := os.Chmod(current, DefaultDirPerm); err != nil { //nolint:gosec // parent dirs must be traversable by the runtime user - if os.IsNotExist(err) || os.IsPermission(err) { - break - } - return fmt.Errorf("chmod %s: %w", current, err) +func init() { + dropToImpl = func(account *Account) error { + if err := syscall.Setgid(account.GID); err != nil { + return fmt.Errorf("setgid %d: %w", account.GID, err) } - parent := filepath.Dir(current) - if parent == current { - break - } - current = parent - } - return nil -} - -func applyOwnershipAndModes(root string, account *Account, dirPerm os.FileMode, filePerm os.FileMode) error { - return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - info, err := entry.Info() - if err != nil { - return err - } - if os.Geteuid() == 0 { - if chownErr := os.Chown(path, account.UID, account.GID); chownErr != nil && !os.IsNotExist(chownErr) { //nolint:gosec // path is under managed root walk - return fmt.Errorf("chown %s: %w", path, chownErr) - } - } - if entry.IsDir() { - if chmodErr := os.Chmod(path, dirPerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk - return fmt.Errorf("chmod dir %s: %w", path, chmodErr) - } - return nil - } - if !info.Mode().IsRegular() { - return nil - } - mode := filePerm - if chmodErr := os.Chmod(path, mode); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk - return fmt.Errorf("chmod file %s: %w", path, chmodErr) + if err := syscall.Setuid(account.UID); err != nil { + return fmt.Errorf("setuid %d: %w", account.UID, err) } return nil - }) -} - -func ensureModesOnly(root string, dirPerm os.FileMode, filePerm os.FileMode) error { - return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - info, err := entry.Info() - if err != nil { - return err - } - if entry.IsDir() { - if chmodErr := os.Chmod(path, dirPerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk - return fmt.Errorf("chmod dir %s: %w", path, chmodErr) - } - return nil - } - if !info.Mode().IsRegular() { - return nil - } - if chmodErr := os.Chmod(path, filePerm); chmodErr != nil && !os.IsNotExist(chmodErr) { //nolint:gosec // path is under managed root walk - return fmt.Errorf("chmod file %s: %w", path, chmodErr) - } - return nil - }) + } } \ No newline at end of file diff --git a/internal/model/openflare_option.go b/internal/model/openflare_option.go index d9e266af..32145b44 100644 --- a/internal/model/openflare_option.go +++ b/internal/model/openflare_option.go @@ -109,7 +109,7 @@ const defaultOpenRestyMainConfigTemplate = `# This file is generated by OpenFlar user openflare; worker_processes {{OpenRestyWorkerProcesses}}; worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}}; -pid logs/nginx.pid; +pid __OPENFLARE_PID_PATH__; error_log {{OpenRestyErrorLogPath}} warn; events { @@ -120,6 +120,11 @@ http { include mime.types; default_type application/octet-stream; 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; {{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}'; access_log {{OpenRestyAccessLogPath}} openflare_json; sendfile on; diff --git a/pkg/render/openresty/types.go b/pkg/render/openresty/types.go index abb916b9..92f5166d 100644 --- a/pkg/render/openresty/types.go +++ b/pkg/render/openresty/types.go @@ -7,6 +7,8 @@ const ( RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__" AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__" ErrorLogPlaceholder = "__OPENFLARE_ERROR_LOG__" + PIDPathPlaceholder = "__OPENFLARE_PID_PATH__" + NginxCacheDirPlaceholder = "__OPENFLARE_NGINX_CACHE_DIR__" LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__" ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__" ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__" @@ -36,7 +38,7 @@ const defaultMainConfigTemplate = `# This file is generated by OpenFlare. Do not user ` + OpenFlareRuntimeUser + `; worker_processes {{OpenRestyWorkerProcesses}}; worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}}; -pid logs/nginx.pid; +pid __OPENFLARE_PID_PATH__; error_log {{OpenRestyErrorLogPath}} warn; events { @@ -47,6 +49,11 @@ http { include mime.types; default_type application/octet-stream; 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; {{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}'; access_log {{OpenRestyAccessLogPath}} openflare_json; sendfile on;