mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-13 05:44:24 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c2304d4f3 | |||
| 96a17ae35d | |||
| fec9bd18dd | |||
| 6f63715182 |
+10
-1
@@ -8,7 +8,16 @@ sidebar: false
|
||||
|
||||
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
|
||||
|
||||
## [Unreleased]
|
||||
## [v3.5.8] - 2026-10-08
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- 在「系统管理 → 系统配置 → 业务配置」新增可信代理范围设置,默认信任 Cloudflare 官方网段,支持追加、覆盖或关闭;识别出的访客真实 IP 会用于访问日志、WAF 判断和上游请求,并兼容已有自定义 OpenResty 主模板。
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 修复 Casdoor 等第三方认证源使用字符串用户 ID 时登录回调失败的问题;自动注册不会再与已有本地用户或其他认证源账号发生 ID 冲突,已绑定账号不受影响。
|
||||
- 修复 Agent 在 OpenResty 运行状态异常时可能重复启动并导致端口占用的问题;现可恢复已有运行实例,避免节点配置应用失败。
|
||||
|
||||
## [v3.5.7] - 2026-10-07
|
||||
|
||||
|
||||
@@ -156,4 +156,6 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/unin
|
||||
| --- |---------------------------------------------------------------------------------------------------------|
|
||||
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
|
||||
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
|
||||
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;在节点详情页点击「强制同步」,或重新发布新版本 |
|
||||
| 发布后重复失败 | Agent 会按指数退避自动强制同步,初始间隔 10 秒,最长间隔 5 分钟,直到目标版本应用成功;回滚至旧配置也会继续重试。可在节点详情页点击「强制同步」立即触发 |
|
||||
| PID 文件为空或失效,但端口仍被占用 | Agent 会核对 OpenResty master 的进程身份,恢复 PID 文件并重载现有进程;按配置路径和进程父子关系识别本实例,其他 nginx 实例不会阻塞启动。若出现多个匹配的 master,会报告错误并拒绝重复启动 |
|
||||
| OpenResty 重启等待超时 | 重启会先发送优雅退出信号,最多等待 10 秒让本实例旧 master 和 worker 退出(持续跟踪已识别的 worker,即使其被重新托管);超时会保留错误,不会启动第二个实例。检查仍未结束的连接及进程后再重试 |
|
||||
|
||||
@@ -60,6 +60,8 @@ https://openflare.example.com/login
|
||||
|
||||
如果希望只允许已有用户使用 SSO,可以关闭用户注册。未绑定的第三方账号会进入绑定已有账号流程。
|
||||
|
||||
第三方身份通过「认证源 + `sub`」关联本地用户;自动注册时独立生成本地用户 ID,不使用第三方的 `id` 或数字 `sub` 作为本地用户主键。升级不会修改已有用户 ID 或账号绑定。
|
||||
|
||||
## 修改认证源
|
||||
|
||||
修改认证源时,Client Secret 输入框留空表示保留已有密钥;填写新值则会覆盖保存。
|
||||
@@ -68,6 +70,12 @@ https://openflare.example.com/login
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Casdoor 授权后提示「服务器内部错误,请稍后重试」
|
||||
|
||||
旧版本将 ID Token 中非标准的 `id` 字段按整数解析,而 Casdoor 返回的 `id` 是字符串(通常为 UUID),可能导致授权成功后回调失败。请升级到包含此修复的版本,并从登录页重新发起授权,不要刷新或重复使用旧的回调链接。
|
||||
|
||||
如果升级后仍失败,请在浏览器开发者工具的 Network 中检查 `POST /api/v1/oauth/callback` 的状态码和响应 `error_msg`,以区分令牌兑换、ID Token 校验或数据库错误。排查时不要分享授权码、Token、Client Secret 或完整用户信息。
|
||||
|
||||
### 返回 `invalid_scope`
|
||||
|
||||
说明第三方平台不允许当前配置的 Scope。OIDC 默认 Scope 是 `openid profile email`。请到认证源编辑页调整 Scope,或在第三方平台放行对应 Scope。
|
||||
|
||||
@@ -259,6 +259,9 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||
| `openresty_default_limit_req_per_ip` | `string` | 站点未配置时的默认单 IP 请求频率限制(如 `10r/s`、`100r/m`);空表示默认关闭 | 空 |
|
||||
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
|
||||
| `openresty_trusted_proxy_cidrs` | `string` (JSON 数组) | 可读取 `CF-Connecting-IP` 的可信代理 IPv4/IPv6 CIDR 列表;默认包含 Cloudflare 官方 22 个 IPv4/IPv6 网段;显式 `[]` 会关闭自动生成的可信代理配置 | Cloudflare 官方网段 |
|
||||
|
||||
在管理端「系统管理 → 系统配置 → 业务配置」中编辑 `openresty_trusted_proxy_cidrs`。默认值为 Cloudflare 官方完整 22 个 IPv4/IPv6 网段,可从 [Cloudflare IPv4 网段](https://www.cloudflare.com/ips-v4) 和 [IPv6 网段](https://www.cloudflare.com/ips-v6) 核对并按官方更新维护。若要信任额外内网代理,应将其 CIDR 追加到现有数组;例如 `10.2.0.227` 只有在确认它是可信代理、且它发送的 `CF-Connecting-IP` 真实可靠后,才追加 `10.2.0.227/32`。该地址不属于默认信任范围。显式设置为 `[]` 会关闭由此配置生成的转发头信任;已有显式空值不会在升级时自动改为默认。自定义主模板中手工写入的 Real-IP 指令仍按模板生效。源站请求必须带有真实的 `CF-Connecting-IP`。保存后需重新生成并发布节点配置版本。Nginx Real-IP 会按 `CF-Connecting-IP` 更新 `$remote_addr`,访问日志、WAF 和上游 `X-Real-IP` 使用这个单一客户端地址;`X-Forwarded-For` 保留原代理链并追加恢复后的客户端地址。旧版自定义主模板即使没有新占位符,也会在 `http {}` 中自动插入 Real-IP 指令;若模板已有 `real_ip_header`,该模板控制使用的头,设置仅追加可信来源 CIDR。数据库中缺少该配置项时使用 Cloudflare 默认网段;非法 JSON 或读取配置失败时按空列表关闭信任。
|
||||
|
||||
### 7. 源站错误页 (Origin Error Page)
|
||||
|
||||
|
||||
@@ -112,9 +112,11 @@ func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string)
|
||||
|
||||
// PathExecutor runs OpenResty using a configured binary and config path.
|
||||
type PathExecutor struct {
|
||||
Path string
|
||||
ConfigPath string
|
||||
Runner CommandRunner
|
||||
Path string
|
||||
ConfigPath string
|
||||
Runner CommandRunner
|
||||
inspectProcesses func() ([]runtimeProcess, error)
|
||||
signalProcess func(runtimeProcess, bool) error
|
||||
}
|
||||
|
||||
// Test validates the current OpenResty configuration.
|
||||
@@ -130,21 +132,7 @@ func (e *PathExecutor) Test(ctx context.Context) error {
|
||||
|
||||
// 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
|
||||
return e.recoverRuntime(ctx)
|
||||
}
|
||||
|
||||
// EnsureRuntime validates configuration and reloads the OpenResty runtime.
|
||||
@@ -162,20 +150,7 @@ func (e *PathExecutor) CheckHealth(ctx context.Context) error {
|
||||
|
||||
// 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
|
||||
return e.restartRuntime(ctx)
|
||||
}
|
||||
|
||||
// Manager applies OpenResty configuration and manages runtime assets.
|
||||
@@ -197,6 +172,7 @@ type Manager struct {
|
||||
Executor Executor
|
||||
atomicFileWriter func(path string, data []byte, perm os.FileMode) error
|
||||
wafIPGroupsMu sync.Mutex
|
||||
runtimeMu sync.Mutex
|
||||
}
|
||||
|
||||
// ApplyStatus reports the outcome of an OpenResty configuration apply.
|
||||
@@ -261,6 +237,8 @@ type wafIPGroupsRuntimeConfig struct {
|
||||
|
||||
// Apply writes, validates, and activates new OpenResty configuration files.
|
||||
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
|
||||
backup, err := m.backup()
|
||||
if err != nil {
|
||||
@@ -364,7 +342,7 @@ func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupSt
|
||||
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 {
|
||||
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)
|
||||
@@ -426,6 +404,8 @@ func (m *Manager) EnsureLuaAssets() error {
|
||||
|
||||
// EnsureRuntime validates and reloads the current OpenResty runtime configuration.
|
||||
func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -435,6 +415,12 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
|
||||
// EnsureSafeFallbackRuntime starts a minimal safe default OpenResty runtime.
|
||||
func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
return m.ensureSafeFallbackRuntime(ctx, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string) error {
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -455,6 +441,8 @@ func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string)
|
||||
|
||||
// CheckHealth verifies that OpenResty configuration and health endpoints are available.
|
||||
func (m *Manager) CheckHealth(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -471,6 +459,8 @@ func (m *Manager) CheckHealth(ctx context.Context) error {
|
||||
|
||||
// Restart restarts the OpenResty runtime process.
|
||||
func (m *Manager) Restart(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -480,6 +470,8 @@ func (m *Manager) Restart(ctx context.Context) error {
|
||||
|
||||
// CurrentChecksum returns a stable checksum for the active OpenResty configuration bundle.
|
||||
func (m *Manager) CurrentChecksum() (string, error) {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.RouteConfigPath == "" {
|
||||
return "", errors.New("route config path 不能为空")
|
||||
}
|
||||
@@ -710,7 +702,7 @@ func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr erro
|
||||
resultErr = closeErr
|
||||
}
|
||||
}
|
||||
_ = os.Remove(tempPath)
|
||||
_ = os.Remove(tempPath) //nolint:gosec // Temp path is created by os.CreateTemp in the managed destination directory.
|
||||
}()
|
||||
if err = tempFile.Chmod(perm); err != nil {
|
||||
return err
|
||||
@@ -725,7 +717,7 @@ func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr erro
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err = os.Rename(tempPath, path); err != nil {
|
||||
if err = os.Rename(tempPath, path); err != nil { //nolint:gosec // Destination is an Agent-managed runtime file (including the validated pid directive).
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -817,24 +809,6 @@ func parseExtVersion(output string) string {
|
||||
|
||||
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
|
||||
|
||||
@@ -104,9 +104,10 @@ func (e *scriptedExecutor) Restart(ctx context.Context) error {
|
||||
func TestPathExecutorCommands(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
}
|
||||
|
||||
if err := executor.Test(context.Background()); err != nil {
|
||||
@@ -118,7 +119,7 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
|
||||
expected := []runCall{
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
t.Fatalf("unexpected calls: %#v", runner.calls)
|
||||
@@ -128,9 +129,10 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
}
|
||||
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
|
||||
t.Fatalf("EnsureRuntime failed: %v", err)
|
||||
@@ -140,47 +142,34 @@ func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) == 2 && args[0] == "-s" && args[1] == "quit" {
|
||||
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
|
||||
}
|
||||
return []byte(""), nil
|
||||
},
|
||||
}
|
||||
func TestPathExecutorRestartStartsWhenNoProcesses(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
}
|
||||
if err := executor.Restart(context.Background()); err != nil {
|
||||
t.Fatalf("Restart failed: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected 2 restart calls, got %d", len(runner.calls))
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 start call, got %d", len(runner.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathExecutorReloadStartsWhenRuntimeIsNotRunning(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 2 && args[0] == "-s" && args[1] == "reload" {
|
||||
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
|
||||
}
|
||||
return []byte(""), nil
|
||||
},
|
||||
}
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
expected := []runCall{
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShutdownTimeout = 10 * time.Second
|
||||
runtimeProcessPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// runtimeProcess records an inspected process identity, including its start time
|
||||
// to reject a PID that has since been reused.
|
||||
type runtimeProcess struct {
|
||||
PID int
|
||||
ParentPID int
|
||||
Master bool
|
||||
StartTime string
|
||||
}
|
||||
|
||||
// runtimeProcesses returns candidates; only verified masters and their tracked
|
||||
// descendants establish membership of this instance.
|
||||
func (e *PathExecutor) runtimeProcesses(ctx context.Context) ([]runtimeProcess, error) {
|
||||
if e.inspectProcesses != nil {
|
||||
return e.inspectProcesses()
|
||||
}
|
||||
return inspectOpenrestyProcesses(ctx, e.Path, e.ConfigPath)
|
||||
}
|
||||
|
||||
func (e *PathExecutor) signalRuntime(ctx context.Context, process runtimeProcess, quit bool) error {
|
||||
if e.signalProcess != nil {
|
||||
return e.signalProcess(process, quit)
|
||||
}
|
||||
return signalOpenrestyProcess(ctx, e.Path, e.ConfigPath, process, quit)
|
||||
}
|
||||
|
||||
func findRuntimeMaster(processes []runtimeProcess) (runtimeProcess, error) {
|
||||
var master runtimeProcess
|
||||
for _, process := range processes {
|
||||
if !process.Master {
|
||||
continue
|
||||
}
|
||||
if master.PID != 0 {
|
||||
return runtimeProcess{}, errors.New("multiple matching openresty masters; refusing ambiguous runtime recovery")
|
||||
}
|
||||
master = process
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// runtimeMasterIdentities seeds instance membership from verified masters.
|
||||
func runtimeMasterIdentities(processes []runtimeProcess) map[int]string {
|
||||
identities := make(map[int]string)
|
||||
for _, process := range processes {
|
||||
if process.Master {
|
||||
identities[process.PID] = process.StartTime
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// runtimeDescendants selects known identities and their children. Keeping the
|
||||
// identities between shutdown polls also recognizes workers reparented to init.
|
||||
func runtimeDescendants(processes []runtimeProcess, identities map[int]string) []runtimeProcess {
|
||||
selected := make(map[int]bool)
|
||||
for _, process := range processes {
|
||||
if start, ok := identities[process.PID]; ok && start == process.StartTime {
|
||||
selected[process.PID] = true
|
||||
}
|
||||
}
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for _, process := range processes {
|
||||
if !selected[process.PID] && selected[process.ParentPID] {
|
||||
selected[process.PID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var result []runtimeProcess
|
||||
for _, process := range processes {
|
||||
if selected[process.PID] {
|
||||
identities[process.PID] = process.StartTime
|
||||
result = append(result, process)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *PathExecutor) recoverRuntime(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
processes, err := e.runtimeProcesses(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect openresty runtime: %w", err)
|
||||
}
|
||||
master, err := findRuntimeMaster(processes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if master.PID != 0 {
|
||||
repaired, err := e.repairPIDFile(master.PID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.signalRuntime(ctx, master, false); err != nil {
|
||||
return fmt.Errorf("reload recovered openresty master: %w", err)
|
||||
}
|
||||
if repaired {
|
||||
slog.WarnContext(ctx, "recovered openresty master with invalid pid file", "pid", master.PID, "config", e.ConfigPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
|
||||
func (e *PathExecutor) startRuntime(ctx context.Context) error {
|
||||
output, err := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("openresty start failed: %w: %s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var pidDirectivePattern = regexp.MustCompile(`(?m)^\s*pid\s+([^;\n]+);`)
|
||||
|
||||
func (e *PathExecutor) repairPIDFile(pid int) (bool, error) {
|
||||
config, err := os.ReadFile(e.ConfigPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read config for pid recovery: %w", err)
|
||||
}
|
||||
matches := pidDirectivePattern.FindAllSubmatch(config, -1)
|
||||
if len(matches) != 1 {
|
||||
return false, errors.New("pid recovery requires one explicit pid directive")
|
||||
}
|
||||
path := strings.Trim(string(matches[0][1]), " \t\"'")
|
||||
if !filepath.IsAbs(path) {
|
||||
return false, errors.New("pid recovery requires an absolute pid path")
|
||||
}
|
||||
expected := strconv.Itoa(pid) + "\n"
|
||||
//nolint:gosec // PID path comes from the validated, Agent-managed main configuration.
|
||||
if data, err := os.ReadFile(path); err == nil && string(data) == expected {
|
||||
return false, nil
|
||||
}
|
||||
if err := writeAtomicFile(path, []byte(strconv.Itoa(pid)+"\n"), nginxConfigFilePerm); err != nil {
|
||||
return false, fmt.Errorf("repair openresty pid file: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *PathExecutor) restartRuntime(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
processes, err := e.runtimeProcesses(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
master, err := findRuntimeMaster(processes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if master.PID == 0 {
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
identities := runtimeMasterIdentities(processes)
|
||||
runtimeDescendants(processes, identities)
|
||||
if err := e.signalRuntime(ctx, master, true); err != nil {
|
||||
return fmt.Errorf("stop openresty master: %w", err)
|
||||
}
|
||||
// A successful signal only acknowledges delivery, not process termination.
|
||||
// Never create a second master while the old master or workers remain.
|
||||
waitCtx, cancel := context.WithTimeout(ctx, runtimeShutdownTimeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(runtimeProcessPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
remaining, err := e.runtimeProcesses(waitCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(runtimeDescendants(remaining, identities)) == 0 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return fmt.Errorf("waiting for openresty processes to exit before restart: %w", waitCtx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
|
||||
func matchesRuntimeMaster(command, configPath string) bool {
|
||||
command = strings.ReplaceAll(command, "\x00", " ")
|
||||
if !strings.HasPrefix(command, "nginx: master process ") && !strings.HasPrefix(command, "openresty: master process ") {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(command)
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "-c" {
|
||||
return filepath.IsAbs(fields[i+1]) && filepath.Clean(fields[i+1]) == filepath.Clean(configPath)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func inspectOpenrestyProcesses(ctx context.Context, binary, configPath string) ([]runtimeProcess, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executable, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inspectProcRuntime("/proc", executable, binary, configPath, os.Geteuid())
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(ctx context.Context, binary, config string, process runtimeProcess, quit bool) error {
|
||||
processes, err := inspectOpenrestyProcesses(ctx, binary, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range processes {
|
||||
if current.PID == process.PID && current.Master && current.StartTime == process.StartTime {
|
||||
signal := syscall.SIGHUP
|
||||
if quit {
|
||||
signal = syscall.SIGQUIT
|
||||
}
|
||||
return syscall.Kill(current.PID, signal)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("openresty master identity changed before signalling pid %d", process.PID)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !unix
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func inspectOpenrestyProcesses(_ context.Context, _, _ string) ([]runtimeProcess, error) {
|
||||
return nil, errors.New("safe openresty process recovery is unsupported on this platform; refusing an unverified start")
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(_ context.Context, _, _ string, _ runtimeProcess, _ bool) error {
|
||||
return errors.New("safe openresty process signalling is unsupported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
const procParentPIDIndex = 1
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
const procStartTimeIndex = 19 // Field 22, counting from state after the process name.
|
||||
|
||||
//nolint:unused // Used by the Linux executor; portable for process fixture tests.
|
||||
func inspectProcRuntime(root string, executable os.FileInfo, binary, configPath string, uid int) ([]runtimeProcess, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var processes []runtimeProcess
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || pid <= 0 || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
process, found, err := inspectProcProcess(filepath.Join(root, entry.Name()), pid, executable, binary, configPath, uid)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
func inspectProcProcess(dir string, pid int, executable os.FileInfo, binary, configPath string, uid int) (runtimeProcess, bool, error) {
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
status, err := os.ReadFile(filepath.Join(dir, "status"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
owned := effectiveProcessUID(string(status)) == uid
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
command, err := os.ReadFile(filepath.Join(dir, "cmdline"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
title := strings.ReplaceAll(string(command), "\x00", " ")
|
||||
if !strings.HasPrefix(title, "nginx: ") && !strings.HasPrefix(title, "openresty: ") {
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
master := owned && matchesRuntimeMaster(title, configPath)
|
||||
exe, err := os.Stat(filepath.Join(dir, "exe"))
|
||||
if err != nil {
|
||||
if !os.IsPermission(err) {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
// Non-master candidates are only retained by runtimeDescendants when
|
||||
// their parent belongs to this instance. A capability-enabled non-root
|
||||
// master may be non-dumpable, making /proc/PID/exe inaccessible; verify
|
||||
// its invocation and UID instead.
|
||||
fields := strings.Fields(title)
|
||||
if master && (len(fields) < 4 || fields[3] != binary) {
|
||||
return runtimeProcess{}, false, fmt.Errorf("cannot verify executable of openresty master %d", pid)
|
||||
}
|
||||
} else if !os.SameFile(executable, exe) {
|
||||
if master {
|
||||
return runtimeProcess{}, false, fmt.Errorf("executable changed for openresty master %d; refusing duplicate start", pid)
|
||||
}
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
closeParen := strings.LastIndexByte(string(stat), ')')
|
||||
if closeParen < 0 {
|
||||
return runtimeProcess{}, false, fmt.Errorf("invalid stat for process %d", pid)
|
||||
}
|
||||
fields := strings.Fields(string(stat)[closeParen+1:])
|
||||
if len(fields) <= procStartTimeIndex {
|
||||
return runtimeProcess{}, false, fmt.Errorf("incomplete stat for process %d", pid)
|
||||
}
|
||||
if fields[0] == "Z" {
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
parentPID, err := strconv.Atoi(fields[procParentPIDIndex])
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, fmt.Errorf("invalid parent pid for process %d: %w", pid, err)
|
||||
}
|
||||
return runtimeProcess{PID: pid, ParentPID: parentPID, Master: master, StartTime: fields[procStartTimeIndex]}, true, nil
|
||||
}
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
func effectiveProcessUID(status string) int {
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "Uid:" {
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err == nil {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectProcRuntime(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := filepath.Join(root, "nginx.conf")
|
||||
writeProcFixture(t, root, 42, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", binary)
|
||||
// Root masters may run workers as a different user. Track them during shutdown.
|
||||
writeProcFixture(t, root, 43, 42, 1001, "nginx: worker process", "S", binary)
|
||||
writeProcFixture(t, root, 44, 42, 1000, "nginx: worker process", "Z", binary)
|
||||
writeProcFixture(t, root, 45, 1, 1000, "unrelated command", "S", binary)
|
||||
// Another instance using the same executable must not block this instance.
|
||||
writeProcFixture(t, root, 48, 1, 1000, "nginx: master process "+binary+" -c /other/nginx.conf", "S", binary)
|
||||
writeProcFixture(t, root, 49, 48, 1000, "nginx: worker process", "S", binary)
|
||||
otherBinary := filepath.Join(root, "other")
|
||||
if err := os.WriteFile(otherBinary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProcFixture(t, root, 46, 1, 1000, "nginx: worker process", "S", otherBinary)
|
||||
processes, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 {
|
||||
t.Fatalf("inspected processes = %v, want live master and worker", processes)
|
||||
}
|
||||
if processes[0] != (runtimeProcess{PID: 42, ParentPID: 1, Master: true, StartTime: "142"}) {
|
||||
t.Errorf("master identity = %v, want PID 42 and start time 142", processes[0])
|
||||
}
|
||||
if processes[1].PID != 43 || processes[1].Master {
|
||||
t.Errorf("worker identity = %v, want worker 43", processes[1])
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(root, "42")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remaining, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if master, err := findRuntimeMaster(remaining); err != nil || master.PID != 0 {
|
||||
t.Errorf("unattributed worker blocks cold start: master=%v err=%v", master, err)
|
||||
}
|
||||
writeProcFixture(t, root, 47, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", otherBinary)
|
||||
if _, err := inspectProcRuntime(root, executable, binary, config, 1000); err == nil {
|
||||
t.Error("inspectProcRuntime(replaced executable) = nil, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectProcRuntimeIgnoresDifferentConfigOrOwner(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
uid int
|
||||
config string
|
||||
}{
|
||||
{name: "different config", uid: 1000, config: "/other/nginx.conf"},
|
||||
{name: "different owner", uid: 1001, config: "/data/nginx.conf"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProcFixture(t, root, 42, 1, tc.uid, "nginx: master process "+binary+" -c "+tc.config, "S", binary)
|
||||
processes, err := inspectProcRuntime(root, executable, binary, "/data/nginx.conf", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if master, err := findRuntimeMaster(processes); err != nil || master.PID != 0 {
|
||||
t.Errorf("unrelated master blocks cold start: master=%v err=%v", master, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeProcFixture(t *testing.T, root string, pid, parentPID, uid int, title, state, binary string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, strconv.Itoa(pid))
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := make([]string, 20)
|
||||
for i := range fields {
|
||||
fields[i] = "0"
|
||||
}
|
||||
fields[0] = state
|
||||
fields[procParentPIDIndex] = strconv.Itoa(parentPID)
|
||||
fields[19] = strconv.Itoa(pid + 100)
|
||||
for name, content := range map[string]string{
|
||||
"status": fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid),
|
||||
"cmdline": title + "\x00",
|
||||
"stat": fmt.Sprintf("%d (nginx worker) %s\n", pid, strings.Join(fields, " ")),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(binary, filepath.Join(dir, "exe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectProcRuntimeScopesUnreadableExecutables(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can read the permission-denied fixture")
|
||||
}
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := filepath.Join(root, "denied")
|
||||
if err := os.Mkdir(denied, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inaccessible := filepath.Join(denied, "exe")
|
||||
if err := os.Symlink(binary, inaccessible); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := "/data/nginx.conf"
|
||||
writeProcFixture(t, root, 42, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", inaccessible)
|
||||
writeProcFixture(t, root, 43, 42, 1001, "nginx: worker process", "S", inaccessible)
|
||||
writeProcFixture(t, root, 50, 1, 1000, "nginx: master process "+binary+" -c /other/nginx.conf", "S", inaccessible)
|
||||
writeProcFixture(t, root, 51, 50, 1001, "nginx: worker process", "S", inaccessible)
|
||||
if err := os.Chmod(denied, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chmod(denied, 0o700); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if _, err := os.Stat(filepath.Join(root, "51", "exe")); !os.IsPermission(err) {
|
||||
t.Fatalf("fixture exe read = %v, want permission denied", err)
|
||||
}
|
||||
processes, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 || processes[0].PID != 42 || processes[1].PID != 43 {
|
||||
t.Fatalf("instance processes = %v, want own master and worker", processes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the non-Linux Unix inspector.
|
||||
const (
|
||||
psCommandIndex = 8
|
||||
psMasterBinaryIndex = 11
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the non-Linux Unix inspector; portable for fixture tests.
|
||||
func parsePSRuntime(output, binary, configPath string, owner int) ([]runtimeProcess, error) {
|
||||
var processes []runtimeProcess
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) <= psCommandIndex {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
title := strings.Join(fields[psCommandIndex:], " ")
|
||||
if !strings.HasPrefix(title, "nginx: ") && !strings.HasPrefix(title, "openresty: ") {
|
||||
continue
|
||||
}
|
||||
master := uid == owner && matchesRuntimeMaster(title, configPath)
|
||||
if master && (len(fields) <= psMasterBinaryIndex || fields[psMasterBinaryIndex] != binary) {
|
||||
return nil, fmt.Errorf("cannot verify openresty master invocation %s", fields[0])
|
||||
}
|
||||
pid, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentPID, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processes = append(processes, runtimeProcess{PID: pid, ParentPID: parentPID, Master: master, StartTime: strings.Join(fields[3:psCommandIndex], " ")})
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePSRuntimeInstanceMembership(t *testing.T) {
|
||||
output := `42 1 1000 Wed Oct 7 16:00:00 2026 nginx: master process openresty -c /data/nginx.conf
|
||||
43 42 1001 Wed Oct 7 16:00:01 2026 nginx: worker process
|
||||
50 1 1000 Wed Oct 7 16:00:00 2026 nginx: master process openresty -c /other/nginx.conf
|
||||
51 50 1001 Wed Oct 7 16:00:01 2026 nginx: worker process`
|
||||
processes, err := parsePSRuntime(output, "openresty", "/data/nginx.conf", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 || processes[0].PID != 42 || processes[1].PID != 43 || processes[1].ParentPID != 42 {
|
||||
t.Fatalf("instance processes = %v, want master 42 and worker 43", processes)
|
||||
}
|
||||
if processes[0].StartTime != "Wed Oct 7 16:00:00 2026" {
|
||||
t.Errorf("master start time = %q", processes[0].StartTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReloadRecoversLiveMasterWithEmptyPID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
config := filepath.Join(dir, "nginx.conf")
|
||||
pidPath := filepath.Join(dir, "nginx.pid")
|
||||
if err := os.WriteFile(config, []byte("pid "+pidPath+";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pidPath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) {
|
||||
return []byte(`invalid PID number ""`), errors.New("exit status 1")
|
||||
}}
|
||||
master := runtimeProcess{PID: 42, Master: true, StartTime: "123"}
|
||||
var signalled runtimeProcess
|
||||
executor := &PathExecutor{Path: "openresty", ConfigPath: config, Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{master}, nil },
|
||||
signalProcess: func(process runtimeProcess, quit bool) error {
|
||||
if quit {
|
||||
t.Error("Reload requested quit, want reload")
|
||||
}
|
||||
signalled = process
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload(empty PID, live master) = %v, want nil", err)
|
||||
}
|
||||
if signalled != master {
|
||||
t.Errorf("Reload signalled %v, want %v", signalled, master)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Reload commands = %v, want direct master signal and no start", runner.calls)
|
||||
}
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "42\n" {
|
||||
t.Errorf("recovered PID = %q, want 42 newline", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadStartsWithUnrelatedWorkers(t *testing.T) {
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) { return []byte("invalid pid"), errors.New("failed") }}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{{PID: 99}}, nil },
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err == nil || !strings.Contains(err.Error(), "start failed") {
|
||||
t.Errorf("Reload(unrelated workers) = %v, want attempted start", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Reload commands = %v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartWaitsForOldWorkersBeforeStart(t *testing.T) {
|
||||
inspections := 0
|
||||
quit := false
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) {
|
||||
if !quit || inspections < 3 {
|
||||
t.Errorf("start before shutdown completed: quit=%v inspections=%d", quit, inspections)
|
||||
}
|
||||
return nil, nil
|
||||
}}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
inspections++
|
||||
if inspections == 1 {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, ParentPID: 42}}, nil
|
||||
}
|
||||
if inspections == 2 {
|
||||
return []runtimeProcess{{PID: 43, ParentPID: 1}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, gracefulQuit bool) error { quit = gracefulQuit; return nil },
|
||||
}
|
||||
if err := executor.Restart(context.Background()); err != nil {
|
||||
t.Fatalf("Restart(draining workers) = %v, want nil", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Restart commands=%v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartCancellationNeverStartsSecondMaster(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{{PID: 42, Master: true}}, nil },
|
||||
signalProcess: func(_ runtimeProcess, _ bool) error { return nil },
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := executor.Restart(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Restart(cancelled)=%v, want context.Canceled", err)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Restart(cancelled) commands=%v, want no start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartTimeoutNeverStartsWhileWorkersRemain(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
quits := 0
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
if quits == 0 {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, ParentPID: 42}}, nil
|
||||
}
|
||||
return []runtimeProcess{{PID: 43, ParentPID: 1}}, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, quit bool) error {
|
||||
if quit {
|
||||
quits++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := executor.Restart(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("Restart(draining worker, deadline) = %v, want deadline exceeded", err)
|
||||
}
|
||||
if quits != 1 || len(runner.calls) != 0 {
|
||||
t.Errorf("Restart quit count=%d commands=%v, want one quit and no start", quits, runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadRefusesAmbiguousMasters(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, Master: true}}, nil
|
||||
},
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err == nil {
|
||||
t.Error("Reload(two masters) = nil, want refusal")
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Reload commands = %v, want no start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingRuntimeExecutor struct {
|
||||
fakeExecutor
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
restarted chan struct{}
|
||||
}
|
||||
|
||||
func (e *blockingRuntimeExecutor) Reload(context.Context) error {
|
||||
close(e.entered)
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
func (e *blockingRuntimeExecutor) Restart(context.Context) error { close(e.restarted); return nil }
|
||||
|
||||
func TestManagerApplySerializesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
executor := &blockingRuntimeExecutor{entered: make(chan struct{}), release: make(chan struct{}), restarted: make(chan struct{})}
|
||||
manager := &Manager{MainConfigPath: filepath.Join(dir, "etc", "nginx.conf"), RouteConfigPath: filepath.Join(dir, "etc", "routes.conf"), Executor: executor}
|
||||
applied := make(chan ApplyOutcome, 1)
|
||||
go func() { applied <- manager.Apply(context.Background(), "events {}\nhttp {}", "", nil) }()
|
||||
select {
|
||||
case <-executor.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Apply did not reach reload")
|
||||
}
|
||||
restarted := make(chan error, 1)
|
||||
go func() { restarted <- manager.Restart(context.Background()) }()
|
||||
select {
|
||||
case <-executor.restarted:
|
||||
t.Error("Restart overlapped Apply, want serialized lifecycle")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
close(executor.release)
|
||||
if result := <-applied; result.Status != ApplyStatusSuccess {
|
||||
t.Errorf("Apply status=%v message=%s, want success", result.Status, result.Message)
|
||||
}
|
||||
if err := <-restarted; err != nil {
|
||||
t.Errorf("Restart(after Apply)=%v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairPIDRefusesRelativeOrMissingPath(t *testing.T) {
|
||||
config := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
if err := os.WriteFile(config, []byte("pid logs/nginx.pid;"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor := &PathExecutor{ConfigPath: config}
|
||||
if _, err := executor.repairPIDFile(42); err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Errorf("repairPIDFile(relative path)=%v, want absolute path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeDescendantsTracksOnlyInstanceIdentities(t *testing.T) {
|
||||
processes := []runtimeProcess{
|
||||
{PID: 40, ParentPID: 43, StartTime: "140"}, // Descendant precedes its parent.
|
||||
{PID: 42, Master: true, StartTime: "142"},
|
||||
{PID: 43, ParentPID: 42, StartTime: "143"},
|
||||
{PID: 50, StartTime: "150"}, // Unrelated master and worker.
|
||||
{PID: 51, ParentPID: 50, StartTime: "151"},
|
||||
}
|
||||
identities := runtimeMasterIdentities(processes)
|
||||
if got := runtimeDescendants(processes, identities); len(got) != 3 {
|
||||
t.Fatalf("instance processes = %v, want master and two descendants", got)
|
||||
}
|
||||
remaining := []runtimeProcess{
|
||||
{PID: 43, ParentPID: 1, StartTime: "143"}, // Reparented old worker.
|
||||
{PID: 40, ParentPID: 1, StartTime: "240"}, // Reused PID.
|
||||
{PID: 50, StartTime: "150"},
|
||||
{PID: 51, ParentPID: 50, StartTime: "151"},
|
||||
}
|
||||
got := runtimeDescendants(remaining, identities)
|
||||
if len(got) != 1 || got[0].PID != 43 {
|
||||
t.Fatalf("remaining instance processes = %v, want old worker 43", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartIgnoresUnrelatedProcessesAfterShutdown(t *testing.T) {
|
||||
inspections := 0
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
inspections++
|
||||
processes := []runtimeProcess{{PID: 50}, {PID: 51, ParentPID: 50}}
|
||||
if inspections == 1 {
|
||||
processes = append(processes, runtimeProcess{PID: 42, Master: true})
|
||||
}
|
||||
return processes, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, _ bool) error { return nil },
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := executor.Restart(ctx); err != nil {
|
||||
t.Fatalf("Restart(other nginx instance) = %v, want nil", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Restart commands = %v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build unix && !linux
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Non-Linux Unix systems expose process ownership and start time through ps.
|
||||
func inspectOpenrestyProcesses(ctx context.Context, binary, configPath string) ([]runtimeProcess, error) {
|
||||
output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,ppid=,uid=,lstart=,command=").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect openresty processes: %w", err)
|
||||
}
|
||||
return parsePSRuntime(string(output), binary, configPath, os.Geteuid())
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(ctx context.Context, binary, config string, process runtimeProcess, quit bool) error {
|
||||
processes, err := inspectOpenrestyProcesses(ctx, binary, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range processes {
|
||||
if current == process && current.Master {
|
||||
signal := syscall.SIGHUP
|
||||
if quit {
|
||||
signal = syscall.SIGQUIT
|
||||
}
|
||||
return syscall.Kill(current.PID, signal)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("openresty master identity changed before signalling pid %d", process.PID)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot
|
||||
}
|
||||
if !shouldReportApplyLog(alreadySynced, applyResult.reportResult) {
|
||||
slog.Debug("skipping duplicate apply log report", "version", config.Version, "checksum", config.Checksum, "result", applyResult.reportResult)
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
if applyResult.reportResult != ApplyResultSuccess {
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
|
||||
@@ -215,7 +215,7 @@ func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot
|
||||
slog.Error("report apply log failed", "version", config.Version, "result", applyResult.reportResult, "error", err)
|
||||
return err
|
||||
}
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
if applyResult.reportResult != ApplyResultSuccess {
|
||||
slog.Warn("failed apply log reported", "version", config.Version)
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
|
||||
@@ -732,8 +732,8 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
|
||||
Version: client.config.Version,
|
||||
Checksum: client.config.Checksum,
|
||||
}); err != nil {
|
||||
t.Fatalf("expected warning outcome to keep sync successful, got %v", err)
|
||||
}); err == nil {
|
||||
t.Fatal("SyncOnce(rolled-back target) returned nil, want an error so automatic retry continues")
|
||||
}
|
||||
|
||||
snapshot, err := stateStore.Load()
|
||||
|
||||
@@ -262,7 +262,7 @@ func oidcDiscoveryResponse() *http.Response {
|
||||
}
|
||||
|
||||
type mockClaims struct {
|
||||
ID uint64 `json:"id"`
|
||||
ID any `json:"id"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
@@ -275,12 +275,16 @@ type mockClaims struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// generateMockIDToken signs test claims with numeric IDs or Casdoor-style string IDs.
|
||||
func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string) string {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testRSAPrivateKey}, (&jose.SignerOptions{}).WithType("JWT"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
id, _ := strconv.ParseUint(sub, 10, 64)
|
||||
var id any = sub
|
||||
if numericID, parseErr := strconv.ParseUint(sub, 10, 64); parseErr == nil {
|
||||
id = numericID
|
||||
}
|
||||
claims := mockClaims{
|
||||
ID: id,
|
||||
Issuer: issuer,
|
||||
@@ -705,6 +709,7 @@ func TestLogout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackLoginAndUserInfo covers string provider IDs, repeat logins, and local ID collisions.
|
||||
func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
@@ -719,7 +724,8 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
var state string
|
||||
|
||||
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||
const externalSubject = "a27dfc56-07ae-4c9d-9e5c-99103bd8805f"
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, externalSubject, "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||
|
||||
// Get Login URL first to initialize the session and generate the state
|
||||
@@ -766,15 +772,25 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status)
|
||||
}
|
||||
|
||||
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 88888 {
|
||||
if callbackResp.Data.User == nil {
|
||||
t.Fatal("Callback(Casdoor string ID) returned no user, want logged-in user")
|
||||
}
|
||||
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID == 0 {
|
||||
t.Errorf("unexpected user returned: %+v", callbackResp.Data.User)
|
||||
}
|
||||
|
||||
// Verify user is created in database
|
||||
var user model.User
|
||||
if err := dbConn.First(&user, "id = ?", 88888).Error; err != nil {
|
||||
if err := dbConn.First(&user, "id = ?", callbackResp.Data.User.ID).Error; err != nil {
|
||||
t.Fatalf("user was not created in DB: %v", err)
|
||||
}
|
||||
account, err := repository.FindExternalAccount(context.Background(), 100, externalSubject)
|
||||
if err != nil {
|
||||
t.Fatalf("FindExternalAccount(%q) error = %v, want nil", externalSubject, err)
|
||||
}
|
||||
if account.UserID != user.ID {
|
||||
t.Errorf("FindExternalAccount(%q).UserID = %d, want %d", externalSubject, account.UserID, user.ID)
|
||||
}
|
||||
|
||||
// Extract session cookie
|
||||
cookies := w.Result().Cookies()
|
||||
@@ -795,8 +811,40 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
t.Fatalf("failed to fetch user info, status %d", w2.Code)
|
||||
}
|
||||
|
||||
// The same external subject must reuse its binding on subsequent logins.
|
||||
wRepeatLogin := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, []*http.Cookie{sessionCookie})
|
||||
if wRepeatLogin.Code != http.StatusOK {
|
||||
t.Fatalf("GetLoginURL(existing user) status = %d, want 200", wRepeatLogin.Code)
|
||||
}
|
||||
if err := json.Unmarshal(wRepeatLogin.Body.Bytes(), &loginUrlResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedURL, err = url.Parse(loginUrlResp.Data.AuthorizeURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state = parsedURL.Query().Get("state")
|
||||
wRepeat := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(fmt.Sprintf(`{"state":"%s","code":"repeat_auth_code"}`, state)), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
}, wRepeatLogin.Result().Cookies())
|
||||
if wRepeat.Code != http.StatusOK {
|
||||
t.Fatalf("Callback(existing Casdoor user) status = %d, want 200; body: %s", wRepeat.Code, wRepeat.Body.String())
|
||||
}
|
||||
var repeatResp struct {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(wRepeat.Body.Bytes(), &repeatResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repeatResp.Data.User == nil || repeatResp.Data.User.ID != user.ID {
|
||||
t.Errorf("Callback(existing Casdoor user) user = %+v, want ID %d", repeatResp.Data.User, user.ID)
|
||||
}
|
||||
|
||||
// 5. Test Callback (Login flow - existing user, username collision check)
|
||||
var state2 string
|
||||
if err := dbConn.Create(&model.User{ID: 99999, Username: "local_user", IsActive: true}).Error; err != nil {
|
||||
t.Fatalf("failed to create local user with overlapping external ID: %v", err)
|
||||
}
|
||||
// Callback with same username but different external ID (99999)
|
||||
httpMock2 := newMockOIDCClient(testIssuerURL, testClientID, &state2, "99999", "test_oauth_user", "another@linux.do", "Another User")
|
||||
|
||||
@@ -833,6 +881,9 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w3.Body.Bytes(), &collisionResp)
|
||||
if collisionResp.Data.User == nil || collisionResp.Data.User.ID == 0 || collisionResp.Data.User.ID == 99999 {
|
||||
t.Fatalf("Callback(numeric provider ID) user = %+v, want independent local ID", collisionResp.Data.User)
|
||||
}
|
||||
|
||||
if collisionResp.Data.User.Username != "test_oauth_user-1" {
|
||||
t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username)
|
||||
@@ -891,6 +942,80 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCallbackIdentityIsScopedToAuthSource verifies legacy IDs survive login and
|
||||
// identical subjects from different authentication sources remain separate users.
|
||||
func TestCallbackIdentityIsScopedToAuthSource(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
mockRedis := newMockRedisClient()
|
||||
seedTestAuthSource(t, dbConn)
|
||||
require.NoError(t, dbConn.Create(&model.SystemConfig{
|
||||
Key: model.ConfigKeyRegistrationEnabled, Value: "true", Type: "system",
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.User{
|
||||
ID: 88888, Username: "legacy_user", IsActive: true,
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.ExternalAccount{
|
||||
ID: 1, AuthSourceID: 100, UserID: 88888, ExternalID: "88888",
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.AuthSource{
|
||||
ID: 101, Name: "second-source", Type: model.AuthSourceTypeOIDC,
|
||||
IsActive: true, ClientID: testClientID, ClientSecret: testClientSecret,
|
||||
OpenIDDiscoveryURL: testIssuerURL,
|
||||
}).Error)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sourceID uint64
|
||||
legacy bool
|
||||
}{
|
||||
{name: testSourceName, sourceID: 100, legacy: true},
|
||||
{name: "second-source", sourceID: 101},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var state string
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "provider_user", "provider@example.com", "Provider User")
|
||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||
login := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+tc.name, nil, nil, nil)
|
||||
require.Equal(t, http.StatusOK, login.Code, login.Body.String())
|
||||
var loginResp struct {
|
||||
Data OAuthAuthorizeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(login.Body.Bytes(), &loginResp))
|
||||
authorizeURL, err := url.Parse(loginResp.Data.AuthorizeURL)
|
||||
require.NoError(t, err)
|
||||
state = authorizeURL.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
callback := performRequest(router, http.MethodPost, "/api/v1/oauth/callback",
|
||||
[]byte(fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state)),
|
||||
map[string]string{"Content-Type": "application/json"}, login.Result().Cookies())
|
||||
require.Equal(t, http.StatusOK, callback.Code, callback.Body.String())
|
||||
var callbackResp struct {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(callback.Body.Bytes(), &callbackResp))
|
||||
require.Equal(t, "logged_in", callbackResp.Data.Status)
|
||||
require.NotNil(t, callbackResp.Data.User)
|
||||
if tc.legacy {
|
||||
assert.Equal(t, uint64(88888), callbackResp.Data.User.ID)
|
||||
assert.Equal(t, "legacy_user", callbackResp.Data.User.Username)
|
||||
} else {
|
||||
assert.NotZero(t, callbackResp.Data.User.ID)
|
||||
assert.NotEqual(t, uint64(88888), callbackResp.Data.User.ID)
|
||||
}
|
||||
account, err := repository.FindExternalAccount(context.Background(), tc.sourceID, "88888")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, callbackResp.Data.User.ID, account.UserID)
|
||||
})
|
||||
}
|
||||
var users, accounts int64
|
||||
require.NoError(t, dbConn.Model(&model.User{}).Count(&users).Error)
|
||||
require.NoError(t, dbConn.Model(&model.ExternalAccount{}).Count(&accounts).Error)
|
||||
assert.Equal(t, int64(2), users)
|
||||
assert.Equal(t, int64(2), accounts)
|
||||
}
|
||||
|
||||
func TestCallbackBind(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
|
||||
@@ -531,6 +531,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
||||
appendIfChanged("OpenRestyCacheLockEnabled", strconv.FormatBool(left.CacheLockEnabled), strconv.FormatBool(right.CacheLockEnabled))
|
||||
appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout)
|
||||
appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale)
|
||||
appendIfChanged("OpenRestyTrustedProxyCIDRs", strings.Join(effectiveTrustedProxyCIDRs(left.TrustedProxyCIDRs), ","), strings.Join(effectiveTrustedProxyCIDRs(right.TrustedProxyCIDRs), ","))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerServer", strconv.Itoa(left.DefaultLimitConnPerServer), strconv.Itoa(right.DefaultLimitConnPerServer))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", strconv.Itoa(left.DefaultLimitConnPerIP), strconv.Itoa(right.DefaultLimitConnPerIP))
|
||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||
@@ -612,6 +613,7 @@ func openRestyOptionKeys() []string {
|
||||
"OpenRestyCacheLockEnabled",
|
||||
"OpenRestyCacheLockTimeout",
|
||||
"OpenRestyCacheUseStale",
|
||||
"OpenRestyTrustedProxyCIDRs",
|
||||
"OpenRestyDefaultLimitConnPerServer",
|
||||
"OpenRestyDefaultLimitConnPerIP",
|
||||
"OpenRestyDefaultLimitRate",
|
||||
@@ -625,3 +627,10 @@ func openRestyOptionKeys() []string {
|
||||
"SWOfflineDomains",
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveTrustedProxyCIDRs(cidrs []string) []string {
|
||||
if cidrs == nil {
|
||||
return openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
return cidrs
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/protocol"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"gorm.io/gorm"
|
||||
@@ -101,6 +102,7 @@ type snapshotWAFDocument struct {
|
||||
}
|
||||
|
||||
type openRestyConfigSnapshot struct {
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||
WorkerProcesses string `json:"worker_processes"`
|
||||
WorkerConnections int `json:"worker_connections"`
|
||||
@@ -568,6 +570,7 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
||||
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
||||
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
||||
TrustedProxyCIDRs: getTrustedProxyCIDRsConfig(ctx),
|
||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||
@@ -590,6 +593,26 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func parseTrustedProxyCIDRs(value string) []string {
|
||||
var cidrs []string
|
||||
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
|
||||
return []string{}
|
||||
}
|
||||
return cidrs
|
||||
}
|
||||
|
||||
func getTrustedProxyCIDRsConfig(ctx context.Context) []string {
|
||||
config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyOpenRestyTrustedProxyCIDRs)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[OpenFlareConfig] read trusted proxy CIDRs failed: error=%v", err)
|
||||
return []string{}
|
||||
}
|
||||
return parseTrustedProxyCIDRs(config.Value)
|
||||
}
|
||||
|
||||
func parseOriginErrorPageStatusCodes(raw string) []string {
|
||||
const defaultTag = "500-599"
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config_version
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
)
|
||||
|
||||
func TestTrustedProxyCIDRsSurviveSnapshotRendering(t *testing.T) {
|
||||
snapshot := snapshotDocument{
|
||||
OpenRestyConfig: openRestyConfigSnapshot{
|
||||
MainConfigTemplate: model.DefaultOpenRestyMainConfigTemplate,
|
||||
TrustedProxyCIDRs: parseTrustedProxyCIDRs(`["173.245.48.0/20","2001:db8:1234::/48"]`),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered, err := renderSnapshotConfig(string(data), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"real_ip_header CF-Connecting-IP;", "set_real_ip_from 173.245.48.0/20;", "set_real_ip_from 2001:db8:1234::/48;"} {
|
||||
if !strings.Contains(rendered.MainConfig, expected) {
|
||||
t.Errorf("rendered snapshot missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRsSnapshotEmptyAndLegacyDefaults(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
json string
|
||||
want int
|
||||
}{
|
||||
{name: "explicit empty list remains disabled", json: `{"openresty_config":{"trusted_proxy_cidrs":[]}}`, want: 0},
|
||||
{name: "legacy snapshot defaults to Cloudflare ranges", json: `{"openresty_config":{}}`, want: 22},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rendered, err := renderSnapshotConfig(testCase.json, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(rendered.MainConfig, "set_real_ip_from "); got != testCase.want {
|
||||
t.Fatalf("rendered trusted proxy range count = %d, want %d", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRConfigMissingAndExplicitEmpty(t *testing.T) {
|
||||
cleanup := setupOriginErrorPageSnapshotDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
missing := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if len(missing) != 22 || !slices.Equal(missing, openrestyrender.DefaultTrustedProxyCIDRs()) {
|
||||
t.Fatalf("missing database key defaults to %#v, want the 22 Cloudflare ranges", missing)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Create(&model.SystemConfig{Key: model.ConfigKeyOpenRestyTrustedProxyCIDRs, Value: `[]`, Type: "business"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitEmpty := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if explicitEmpty == nil || len(explicitEmpty) != 0 {
|
||||
t.Fatalf("explicit [] must remain a non-nil empty list, got %#v", explicitEmpty)
|
||||
}
|
||||
payload, err := json.Marshal(snapshotDocument{OpenRestyConfig: buildOpenRestyConfigSnapshot(ctx)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"trusted_proxy_cidrs":[]`) {
|
||||
t.Fatalf("explicit empty list was omitted from snapshot: %s", payload)
|
||||
}
|
||||
rendered, err := renderSnapshotConfig(string(payload), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(rendered.MainConfig, "set_real_ip_from ") {
|
||||
t.Fatal("explicit empty list still trusts proxy ranges")
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).Update("value", "invalid").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ram.ResetForTest()
|
||||
malformed := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if malformed == nil || len(malformed) != 0 {
|
||||
t.Fatalf("malformed configuration must fail closed, got %#v", malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRDiffTreatsLegacyNilAsCloudflareDefaults(t *testing.T) {
|
||||
diffs := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, openRestyConfigSnapshot{TrustedProxyCIDRs: []string{}})
|
||||
for _, diff := range diffs {
|
||||
if diff.Key == "OpenRestyTrustedProxyCIDRs" {
|
||||
if diff.PreviousValue == diff.CurrentValue {
|
||||
t.Fatal("legacy nil and explicit empty list should have different effective values")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("trusted proxy CIDR change was not included in config diff")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package option
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
const (
|
||||
maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
maxSWOfflineDomains = 1000
|
||||
maxTrustedProxyCIDRs = 256
|
||||
)
|
||||
|
||||
var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
@@ -57,6 +59,7 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOpenRestyCacheKeyTemplate: validateOpenRestyCacheKeyTemplate,
|
||||
model.ConfigKeyOpenRestyCacheUseStale: validateOpenRestyCacheUseStale,
|
||||
model.ConfigKeyOpenRestyMainConfigTemplate: validateOpenRestyMainConfigTemplate,
|
||||
model.ConfigKeyOpenRestyTrustedProxyCIDRs: validateOpenRestyTrustedProxyCIDRs,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||
@@ -204,6 +207,23 @@ func validateOpenRestyMainConfigTemplate(key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOpenRestyTrustedProxyCIDRs(key, value string) error {
|
||||
var cidrs []string
|
||||
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
}
|
||||
if len(cidrs) > maxTrustedProxyCIDRs {
|
||||
return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs)
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil || prefix != prefix.Masked() {
|
||||
return fmt.Errorf("%s 包含无效网段 %q", key, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return nil
|
||||
|
||||
@@ -70,6 +70,14 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenRestyTrustedProxyCIDRs(t *testing.T) {
|
||||
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `[]`))
|
||||
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `["173.245.48.0/20","10.2.0.0/24"]`))
|
||||
for _, value := range []string{`null`, `10.2.0.0/24`, `["10.2.0.0/24;return 200"]`, `["]`} {
|
||||
require.Error(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, value), "value %q must be rejected", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOriginErrorPageHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs';
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs';
|
||||
@@ -5,7 +5,9 @@ package migrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -24,7 +27,38 @@ import (
|
||||
// (初始系统配置 + 各期配置迁移/新增 seed:of_options 迁移、文件白名单、磁盘缓存、
|
||||
// 登录会话 TTL、升级源、存储、FRPS Web UI、Pages、OpenResty 限流、单 IP 限频、
|
||||
// 错误页、SW 离线、日志保留期、指标保留期等);新增配置 seed 迁移时需同步更新本常量。
|
||||
const expectedMigratedSystemConfigCount = 96
|
||||
const expectedMigratedSystemConfigCount = 97
|
||||
|
||||
func TestTrustedProxyCIDRMigrationDefaultsMatchRenderer(t *testing.T) {
|
||||
want := openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
if len(want) != 22 {
|
||||
t.Fatalf("renderer default contains %d proxy ranges, want 22", len(want))
|
||||
}
|
||||
for _, migrationPath := range []string{
|
||||
"goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql",
|
||||
"goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql",
|
||||
} {
|
||||
migration, err := fs.ReadFile(migrationFS, migrationPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, ok := strings.CutPrefix(string(migration), "-- +goose Up\nINSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)\nVALUES ('openresty_trusted_proxy_cidrs', '")
|
||||
if !ok {
|
||||
t.Fatalf("%s does not contain the expected CIDR seed", migrationPath)
|
||||
}
|
||||
seed, _, ok = strings.Cut(seed, "', 'business'")
|
||||
if !ok {
|
||||
t.Fatalf("%s CIDR seed value is malformed", migrationPath)
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal([]byte(seed), &got); err != nil {
|
||||
t.Fatalf("decode %s CIDR seed: %v", migrationPath, err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s CIDR seed differs from renderer default", migrationPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseMigrationVersionsAreUniquePerDialect(t *testing.T) {
|
||||
for _, dir := range []string{"goose/postgres", "goose/sqlite"} {
|
||||
@@ -88,6 +122,17 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||
if systemConfigCount != expectedMigratedSystemConfigCount {
|
||||
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount)
|
||||
}
|
||||
var trustedProxyConfig model.SystemConfig
|
||||
if err := sqliteDB.Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).First(&trustedProxyConfig).Error; err != nil {
|
||||
t.Fatalf("Migrate() trusted proxy CIDR config error = %v", err)
|
||||
}
|
||||
var trustedProxyCIDRs []string
|
||||
if err := json.Unmarshal([]byte(trustedProxyConfig.Value), &trustedProxyCIDRs); err != nil {
|
||||
t.Fatalf("decode trusted proxy CIDR config: %v", err)
|
||||
}
|
||||
if !slices.Equal(trustedProxyCIDRs, openrestyrender.DefaultTrustedProxyCIDRs()) {
|
||||
t.Errorf("Migrate() trusted proxy CIDRs = %#v, want default Cloudflare ranges", trustedProxyCIDRs)
|
||||
}
|
||||
|
||||
var logMigrationConfig model.SystemConfig
|
||||
if err := sqliteDB.Where("key = ?", model.ConfigKeyLogDBMigration).First(&logMigrationConfig).Error; err != nil {
|
||||
|
||||
@@ -48,6 +48,7 @@ http {
|
||||
client_header_timeout {{OpenRestyClientHeaderTimeout}};
|
||||
client_body_timeout {{OpenRestyClientBodyTimeout}};
|
||||
client_max_body_size {{OpenRestyClientMaxBodySize}};
|
||||
{{OpenRestyRealIPDirectives}}
|
||||
large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}};
|
||||
send_timeout {{OpenRestySendTimeout}};
|
||||
proxy_connect_timeout {{OpenRestyProxyConnectTimeout}};
|
||||
|
||||
@@ -103,6 +103,7 @@ const (
|
||||
ConfigKeyOpenRestyCacheLockTimeout = "openresty_cache_lock_timeout" // 缓存锁超时
|
||||
ConfigKeyOpenRestyCacheUseStale = "openresty_cache_use_stale" // 缓存失效策略
|
||||
ConfigKeyOpenRestyMainConfigTemplate = "openresty_main_config_template" // 主配置模板
|
||||
ConfigKeyOpenRestyTrustedProxyCIDRs = "openresty_trusted_proxy_cidrs" // 可信代理 CIDR JSON 数组
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||
|
||||
+1
-16
@@ -6,7 +6,6 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
|
||||
// 第三方身份使用 Sub;忽略非标准 id 字段,本地用户 ID 独立生成。
|
||||
type OAuthUserInfo struct {
|
||||
ID uint64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
@@ -26,20 +25,6 @@ type OAuthUserInfo struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// GetID 获取用户 ID
|
||||
func (u *OAuthUserInfo) GetID() uint64 {
|
||||
if u.ID != 0 {
|
||||
return u.ID
|
||||
}
|
||||
// 从 sub 解析(OIDC 格式)
|
||||
if u.Sub != "" {
|
||||
if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// User 用户表实体
|
||||
type User struct {
|
||||
ID uint64 `json:"id,string" gorm:"primaryKey;not null"`
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOAuthUserInfoIgnoresProviderID verifies nonstandard IDs cannot affect OIDC identity parsing.
|
||||
func TestOAuthUserInfoIgnoresProviderID(t *testing.T) {
|
||||
for _, providerID := range []string{`"a27dfc56-07ae-4c9d-9e5c-99103bd8805f"`, `"88888"`, `88888`, `null`} {
|
||||
t.Run(providerID, func(t *testing.T) {
|
||||
payload := `{"id":` + providerID + `,"sub":"external-subject","preferred_username":"oidc-user","email":"oidc@example.com","name":"OIDC User"}`
|
||||
var info OAuthUserInfo
|
||||
if err := json.Unmarshal([]byte(payload), &info); err != nil {
|
||||
t.Fatalf("json.Unmarshal(OAuthUserInfo, id=%s) error = %v, want nil", providerID, err)
|
||||
}
|
||||
want := OAuthUserInfo{
|
||||
Sub: "external-subject",
|
||||
PreferredUsername: "oidc-user",
|
||||
Email: "oidc@example.com",
|
||||
Name: "OIDC User",
|
||||
}
|
||||
if info != want {
|
||||
t.Errorf("json.Unmarshal(OAuthUserInfo, id=%s) = %+v, want %+v", providerID, info, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -199,11 +199,11 @@ func UpdateUser(ctx context.Context, user *model.User) error {
|
||||
}
|
||||
|
||||
// CreateUserFromOAuth creates a user from OAuth profile data and fills userOut.
|
||||
// The local ID is generated independently of the provider's identity claims.
|
||||
func CreateUserFromOAuth(ctx context.Context, userOut *model.User, oauthInfo *model.OAuthUserInfo) error {
|
||||
now := time.Now()
|
||||
userID := oauthInfo.GetID()
|
||||
newUser := model.User{
|
||||
ID: userID,
|
||||
ID: idgen.NextUint64ID(),
|
||||
Username: oauthInfo.Username,
|
||||
Nickname: oauthInfo.Name,
|
||||
Email: oauthInfo.Email,
|
||||
@@ -212,9 +212,6 @@ func CreateUserFromOAuth(ctx context.Context, userOut *model.User, oauthInfo *mo
|
||||
LastLoginAt: now,
|
||||
IsAdmin: false,
|
||||
}
|
||||
if newUser.ID == 0 {
|
||||
newUser.ID = idgen.NextUint64ID()
|
||||
}
|
||||
if err := db.DB(ctx).Create(&newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
@@ -170,7 +171,18 @@ func DedupeSupportFiles(files []SupportFile) []SupportFile {
|
||||
}
|
||||
|
||||
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqRates []string) string {
|
||||
trustedProxyCIDRs := cfg.TrustedProxyCIDRs
|
||||
if trustedProxyCIDRs == nil {
|
||||
trustedProxyCIDRs = DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
trustedProxyDirectives := renderTrustedProxyDirectives(trustedProxyCIDRs, !hasNginxDirective(templateText, "real_ip_header"))
|
||||
if !strings.Contains(templateText, RealIPDirectivesPlaceholder) && trustedProxyDirectives != "" {
|
||||
// This required token expands to a complete map block in the http context.
|
||||
// Injecting before it avoids depending on formatting or splitting directives.
|
||||
templateText = strings.Replace(templateText, "{{OpenRestyConnectionUpgradeMap}}", trustedProxyDirectives+"{{OpenRestyConnectionUpgradeMap}}", 1)
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
RealIPDirectivesPlaceholder, trustedProxyDirectives,
|
||||
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
||||
"{{OpenRestyWorkerConnections}}", strconv.Itoa(cfg.WorkerConnections),
|
||||
"{{OpenRestyWorkerRlimitNofile}}", strconv.Itoa(cfg.WorkerRlimitNofile),
|
||||
@@ -205,6 +217,37 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqR
|
||||
return replacer.Replace(templateText)
|
||||
}
|
||||
|
||||
func hasNginxDirective(templateText string, directive string) bool {
|
||||
for _, line := range strings.Split(templateText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if fields := strings.Fields(line); len(fields) > 0 && fields[0] == directive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderTrustedProxyDirectives(cidrs []string, includeHeader bool) string {
|
||||
if len(cidrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
if includeHeader {
|
||||
builder.WriteString(" real_ip_header CF-Connecting-IP;\n real_ip_recursive on;\n")
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&builder, " set_real_ip_from %s;\n", prefix.Masked())
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderTemplateDirective(enabled bool, statement string) string {
|
||||
if !enabled {
|
||||
return ""
|
||||
|
||||
@@ -9,6 +9,56 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMainConfigTrustedProxyCIDRs(t *testing.T) {
|
||||
base := ConfigSnapshot{MainConfigTemplate: defaultMainConfigTemplate}
|
||||
withDefaultTrust := RenderMainConfig(Document{OpenRestyConfig: base})
|
||||
if count := strings.Count(withDefaultTrust, "set_real_ip_from "); count != 22 {
|
||||
t.Fatalf("default config trusts %d proxy ranges, want all 22 Cloudflare ranges", count)
|
||||
}
|
||||
if !strings.Contains(withDefaultTrust, "set_real_ip_from 173.245.48.0/20;") || !strings.Contains(withDefaultTrust, "set_real_ip_from 2c0f:f248::/32;") {
|
||||
t.Fatal("default config is missing Cloudflare IPv4 or IPv6 ranges")
|
||||
}
|
||||
|
||||
base.TrustedProxyCIDRs = []string{}
|
||||
withoutTrust := RenderMainConfig(Document{OpenRestyConfig: base})
|
||||
if strings.Contains(withoutTrust, "real_ip_header") || strings.Contains(withoutTrust, "set_real_ip_from") {
|
||||
t.Fatal("explicit empty trusted-proxy list must disable forwarded-header trust")
|
||||
}
|
||||
|
||||
base.TrustedProxyCIDRs = []string{"173.245.48.0/20", "10.2.0.0/24", "2001:db8:1234::/48", "192.0.2.7/32"}
|
||||
got := RenderMainConfig(Document{OpenRestyConfig: base})
|
||||
for _, expected := range []string{"real_ip_header CF-Connecting-IP;", "set_real_ip_from 173.245.48.0/20;", "set_real_ip_from 10.2.0.0/24;", "set_real_ip_from 2001:db8:1234::/48;", "set_real_ip_from 192.0.2.7/32;"} {
|
||||
if !strings.Contains(got, expected) {
|
||||
t.Errorf("rendered config missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMainConfigInjectsTrustedProxyForLegacyTemplate(t *testing.T) {
|
||||
legacy := strings.ReplaceAll(defaultMainConfigTemplate, RealIPDirectivesPlaceholder+"\n", "")
|
||||
got := RenderMainConfig(Document{OpenRestyConfig: ConfigSnapshot{MainConfigTemplate: legacy, TrustedProxyCIDRs: []string{"10.2.0.0/24"}}})
|
||||
if !strings.Contains(got, "real_ip_header CF-Connecting-IP;\n real_ip_recursive on;\n set_real_ip_from 10.2.0.0/24;") || !strings.Contains(got, "map $http_upgrade $connection_upgrade {") {
|
||||
t.Fatalf("trusted proxy directives were not injected into legacy template:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "access_log real_ip_header") {
|
||||
t.Fatalf("trusted proxy directives corrupted access_log directive:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMainConfigPreservesExplicitRealIPHeader(t *testing.T) {
|
||||
templateText := strings.Replace(defaultMainConfigTemplate, "{{OpenRestyConnectionUpgradeMap}}", "# real_ip_header in comment\nreal_ip_header X-Forwarded-For;\n{{OpenRestyConnectionUpgradeMap}}", 1)
|
||||
got := RenderMainConfig(Document{OpenRestyConfig: ConfigSnapshot{
|
||||
MainConfigTemplate: templateText,
|
||||
TrustedProxyCIDRs: []string{"192.0.2.7/32"},
|
||||
}})
|
||||
if !strings.Contains(got, "real_ip_header X-Forwarded-For;") || strings.Contains(got, "real_ip_header CF-Connecting-IP;") {
|
||||
t.Fatalf("explicit template header selection was not preserved:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "set_real_ip_from 192.0.2.7/32;") {
|
||||
t.Fatalf("trusted CIDR was not added to explicit template header:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOpenRestyUsesDedicatedWAFIPGroupSharedDict(t *testing.T) {
|
||||
block := renderOpenRestyObservabilityTemplateBlock()
|
||||
if !strings.Contains(block, "lua_shared_dict openflare_waf_config 1m;") {
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
||||
ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"
|
||||
RealIPDirectivesPlaceholder = "{{OpenRestyRealIPDirectives}}"
|
||||
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
|
||||
|
||||
SourceConfigFileName = "openresty_config.json"
|
||||
@@ -102,6 +103,7 @@ http {
|
||||
gzip {{OpenRestyGzip}};
|
||||
gzip_min_length {{OpenRestyGzipMinLength}};
|
||||
gzip_comp_level {{OpenRestyGzipCompLevel}};
|
||||
{{OpenRestyRealIPDirectives}}
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css text/xml application/javascript application/json application/xml application/rss+xml application/atom+xml image/svg+xml;
|
||||
{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
|
||||
@@ -281,6 +283,7 @@ type WAFDocument struct {
|
||||
// ConfigSnapshot holds the full set of OpenResty tuning parameters that are
|
||||
// rendered into the nginx main configuration template.
|
||||
type ConfigSnapshot struct {
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||
WorkerProcesses string `json:"worker_processes"`
|
||||
WorkerConnections int `json:"worker_connections"`
|
||||
@@ -335,6 +338,20 @@ type ConfigSnapshot struct {
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultTrustedProxyCIDRs returns Cloudflare's published IPv4 and IPv6
|
||||
// networks. Callers can override the list, including with an explicit empty
|
||||
// slice to disable trusted-proxy processing.
|
||||
func DefaultTrustedProxyCIDRs() []string {
|
||||
return []string{
|
||||
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
|
||||
"141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
|
||||
"197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
|
||||
"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
|
||||
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32",
|
||||
"2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32",
|
||||
}
|
||||
}
|
||||
|
||||
// Document is the top-level input structure for the OpenResty renderer,
|
||||
// combining routes, OpenResty tuning, and WAF configuration.
|
||||
type Document struct {
|
||||
|
||||
Reference in New Issue
Block a user