Compare commits
No commits in common. "09f4ca57559148882370f0d41215e7ab2fe9b647" and "f33117b940e7346688147eb22fc764b953b28ee8" have entirely different histories.
09f4ca5755
...
f33117b940
@ -1,87 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: |
|
|
||||||
go install mvdan.cc/gofumpt@latest
|
|
||||||
output=$(gofumpt -l .)
|
|
||||||
if [ -n "$output" ]; then
|
|
||||||
echo "Files need formatting:"
|
|
||||||
echo "$output"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install golangci-lint
|
|
||||||
run: |
|
|
||||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.0
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: golangci-lint run ./...
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: go test -race -count=1 ./internal/...
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
needs: [lint, test]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: make build
|
|
||||||
|
|
||||||
- name: Verify binary
|
|
||||||
run: ./bin/lolly --help
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Docker
|
|
||||||
needs: build
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: docker build -t lolly:latest .
|
|
||||||
92
.github/workflows/ci.yml
vendored
92
.github/workflows/ci.yml
vendored
@ -1,92 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: |
|
|
||||||
go install mvdan.cc/gofumpt@latest
|
|
||||||
output=$(gofumpt -l .)
|
|
||||||
if [ -n "$output" ]; then
|
|
||||||
echo "::error::Files need formatting:"
|
|
||||||
echo "$output"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: golangci/golangci-lint-action@v7
|
|
||||||
with:
|
|
||||||
version: v2.1
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: golangci-lint run ./...
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: go test -race -count=1 ./internal/...
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
needs: [lint, test]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: make build
|
|
||||||
|
|
||||||
- name: Verify binary
|
|
||||||
run: ./bin/lolly --help
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: lolly
|
|
||||||
path: bin/lolly
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Docker
|
|
||||||
needs: build
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: docker build -t lolly:latest .
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
# P0: 生产可用性特性设计
|
|
||||||
|
|
||||||
日期: 2026-06-11
|
|
||||||
状态: Approved
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
5 个独立特性,使 lolly 达到生产可部署状态。所有特性互不依赖,可完全并行开发。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. CORS 中间件
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
在 `security.cors` 下新增 `CORSConfig` 结构体,server 级别配置:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
security:
|
|
||||||
cors:
|
|
||||||
enabled: true
|
|
||||||
allowed_origins: ["https://example.com", "https://api.example.com"]
|
|
||||||
allowed_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
|
||||||
allowed_headers: ["Content-Type", "Authorization", "X-Request-ID"]
|
|
||||||
expose_headers: ["X-Total-Count"]
|
|
||||||
allow_credentials: true
|
|
||||||
max_age: 3600
|
|
||||||
```
|
|
||||||
|
|
||||||
字段说明:
|
|
||||||
- `enabled`: 启用开关(默认 false)
|
|
||||||
- `allowed_origins`: 允许的源列表,支持 `"*"` 通配(与 `allow_credentials: true` 互斥,遵循 CORS 规范)
|
|
||||||
- `allowed_methods`: 允许的 HTTP 方法
|
|
||||||
- `allowed_headers`: 允许的请求头
|
|
||||||
- `expose_headers`: 允许前端读取的响应头
|
|
||||||
- `allow_credentials`: 是否允许发送 Cookie
|
|
||||||
- `max_age`: preflight 缓存时间(秒),默认 0
|
|
||||||
|
|
||||||
### 文件
|
|
||||||
|
|
||||||
| 文件 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| `internal/middleware/cors/cors.go` | 新建:CORS 中间件实现 |
|
|
||||||
| `internal/middleware/cors/cors_test.go` | 新建:单元测试 |
|
|
||||||
| `internal/config/security_config.go` | 修改:添加 `CORSConfig` 结构体 |
|
|
||||||
| `internal/config/defaults.go` | 修改:CORS 默认值
|
|
||||||
| `internal/config/validate.go` | 修改:CORS 验证(origins+credentials 互斥检查)
|
|
||||||
| `internal/server/middleware_builder.go` | 修改:注册 CORS 中间件(步骤 7.5,SecurityHeaders 之后、ErrorIntercept 之前)
|
|
||||||
|
|
||||||
### 行为
|
|
||||||
|
|
||||||
1. 非 CORS 请求(无 `Origin` 头):直接 pass-through
|
|
||||||
2. Preflight (OPTIONS):返回 204 + CORS 头,不进入后续 handler
|
|
||||||
3. 实际请求:调用 `next(ctx)` 后添加 CORS 响应头
|
|
||||||
4. Origin 不匹配:不加 CORS 头,浏览器阻止跨域
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Request-ID 传播
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
无需额外配置,默认启用。
|
|
||||||
|
|
||||||
### 文件
|
|
||||||
|
|
||||||
| 文件 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| `internal/middleware/requestid/requestid.go` | 新建:Request-ID 中间件 |
|
|
||||||
| `internal/middleware/requestid/requestid_test.go` | 新建:单元测试 |
|
|
||||||
| `internal/proxy/headers.go` | 修改:`SetForwardedHeaders` 添加 X-Request-ID 传播 |
|
|
||||||
| `internal/server/middleware_builder.go` | 修改:注册为第一个中间件(AccessLog 之前)
|
|
||||||
|
|
||||||
### 行为
|
|
||||||
|
|
||||||
1. 检查入站 `X-Request-ID` 请求头
|
|
||||||
2. 有值 → 复用(信任下游),存入 `ctx.SetUserValue("request_id", id)`
|
|
||||||
3. 无值 → 生成 UUID v4,存入 `ctx.SetUserValue`
|
|
||||||
4. 始终在响应中设置 `X-Request-ID` 头
|
|
||||||
5. 代理转发时,`SetForwardedHeaders` 自动传播 `X-Request-ID`(从 `ctx.UserValue("request_id")` 读取)
|
|
||||||
|
|
||||||
### 与现有代码的关系
|
|
||||||
|
|
||||||
`internal/variable/builtin.go` 已有 `$request_id` 变量,会从 `ctx.UserValue("request_id")` 读取。中间件在请求早期设置此值后,变量系统、access log、proxy header forwarding 都能正确使用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. /healthz + /readyz 端点
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
在 `monitoring` 下新增:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
monitoring:
|
|
||||||
healthz:
|
|
||||||
enabled: true
|
|
||||||
path: "/healthz"
|
|
||||||
readyz:
|
|
||||||
enabled: true
|
|
||||||
path: "/readyz"
|
|
||||||
```
|
|
||||||
|
|
||||||
字段说明:
|
|
||||||
- `enabled`: 默认 true(开箱即用)
|
|
||||||
- `path`: 可自定义路径
|
|
||||||
|
|
||||||
### 文件
|
|
||||||
|
|
||||||
| 文件 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| `internal/server/healthz.go` | 新建:healthz/readyz handler |
|
|
||||||
| `internal/server/healthz_test.go` | 新建:单元测试 |
|
|
||||||
| `internal/config/monitoring_config.go` | 修改:添加 `HealthzConfig`、`ReadyzConfig` |
|
|
||||||
| `internal/config/defaults.go` | 修改:默认值
|
|
||||||
| `internal/server/server.go` | 修改:注册端点(三种模式都需要) |
|
|
||||||
|
|
||||||
### 行为
|
|
||||||
|
|
||||||
**healthz(存活探针)**:
|
|
||||||
- GET → 200 `{"status":"ok"}`
|
|
||||||
- 无任何依赖检查,只要进程活着就返回 200
|
|
||||||
|
|
||||||
**readyz(就绪探针)**:
|
|
||||||
- GET → 200 `{"status":"ready"}` 或 503 `{"status":"not ready","reasons":["no healthy upstreams"]}`
|
|
||||||
- 检查条件:至少有一个 server 已启动 + 至少有一个 upstream 目标可用(如果有配置 proxy 的话)
|
|
||||||
- 无 proxy 配置的纯静态文件服务器永远返回 200
|
|
||||||
|
|
||||||
### 注册位置
|
|
||||||
|
|
||||||
与 status/pprof 同级:
|
|
||||||
- Single 模式:`locationEngine.AddExact`
|
|
||||||
- VHost/Multi 模式:`router.GET`
|
|
||||||
|
|
||||||
默认启用,不需要 IP 白名单(K8s 探针来自 kubelet)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 环境变量插值
|
|
||||||
|
|
||||||
### 语法
|
|
||||||
|
|
||||||
仅 `${ENV_VAR}` 花括号语法,与 lolly 自身 `$variable` 系统无歧义。
|
|
||||||
|
|
||||||
缺失环境变量时保留原样(`${MISSING_VAR}` 不展开)。
|
|
||||||
|
|
||||||
### 文件
|
|
||||||
|
|
||||||
| 文件 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| `internal/config/env.go` | 新建:`ExpandEnv(data []byte) []byte` 函数 |
|
|
||||||
| `internal/config/env_test.go` | 新建:单元测试 |
|
|
||||||
| `internal/config/config.go` | 修改:`Load()` 和 `processIncludes()` 中调用 `ExpandEnv` |
|
|
||||||
|
|
||||||
### 实现
|
|
||||||
|
|
||||||
正则 `\$\{([^}]+)\}` 匹配:
|
|
||||||
- 匹配到 → `os.Getenv(key)`
|
|
||||||
- 环境变量存在 → 替换为值
|
|
||||||
- 环境变量不存在 → 保留 `${key}` 原样
|
|
||||||
|
|
||||||
调用位置:
|
|
||||||
1. `Load()`: `os.ReadFile` 之后、`yaml.Unmarshal` 之前
|
|
||||||
2. `processIncludes()`: 每个被 include 文件 `os.ReadFile` 之后、`yaml.Unmarshal` 之前
|
|
||||||
|
|
||||||
### 示例
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
servers:
|
|
||||||
- listen: "${LISTEN_ADDR}:8080"
|
|
||||||
ssl:
|
|
||||||
cert: "${SSL_CERT_PATH}"
|
|
||||||
key: "${SSL_KEY_PATH}"
|
|
||||||
security:
|
|
||||||
auth:
|
|
||||||
users:
|
|
||||||
- name: admin
|
|
||||||
password: "${ADMIN_PASSWORD_HASH}"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. CI/CD 流水线
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
|
|
||||||
`.github/workflows/ci.yml` 单文件,push to master + PR 触发。
|
|
||||||
|
|
||||||
### Jobs
|
|
||||||
|
|
||||||
| Job | 依赖 | 步骤 |
|
|
||||||
|-----|------|------|
|
|
||||||
| `lint` | 无 | gofumpt 检查 → golangci-lint |
|
|
||||||
| `test` | 无 | `go test -race ./internal/...` |
|
|
||||||
| `build` | lint + test | 多平台静态构建(linux/amd64, linux/arm64, darwin/amd64, darwin/arm64) |
|
|
||||||
| `docker` | build(仅 push/tag) | docker build + push |
|
|
||||||
|
|
||||||
### 文件
|
|
||||||
|
|
||||||
| 文件 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| `.github/workflows/ci.yml` | 新建:GitHub Actions CI 流水线 |
|
|
||||||
|
|
||||||
### 约束
|
|
||||||
|
|
||||||
- Go 1.26
|
|
||||||
- `CGO_ENABLED=0`
|
|
||||||
- E2E 测试需要 Docker service(testcontainers)
|
|
||||||
- 使用 `make fmt`、`make lint`、`make test` 命令
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖关系
|
|
||||||
|
|
||||||
```
|
|
||||||
CORS ──────┐
|
|
||||||
Request-ID ┤── 全部独立,可并行
|
|
||||||
healthz ───┤
|
|
||||||
env interp ┤
|
|
||||||
CI/CD ─────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 提交策略
|
|
||||||
|
|
||||||
每个特性一个独立 commit:
|
|
||||||
1. `feat(middleware/cors): add CORS middleware with server-level config`
|
|
||||||
2. `feat(middleware/requestid): add request ID generation and propagation`
|
|
||||||
3. `feat(server): add /healthz and /readyz endpoints for k8s probes`
|
|
||||||
4. `feat(config): add ${ENV_VAR} interpolation in YAML config`
|
|
||||||
5. `ci: add GitHub Actions CI pipeline`
|
|
||||||
@ -134,8 +134,6 @@ func Load(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data = ExpandEnv(data)
|
|
||||||
|
|
||||||
// 从默认值开始,YAML 只覆盖显式配置的字段。
|
// 从默认值开始,YAML 只覆盖显式配置的字段。
|
||||||
// 注意:yaml.v3 对 slice 会整体替换,因此用户显式配置的 Servers[]
|
// 注意:yaml.v3 对 slice 会整体替换,因此用户显式配置的 Servers[]
|
||||||
// 元素不会继承 server-level 默认值;但顶层 struct 字段(Performance、
|
// 元素不会继承 server-level 默认值;但顶层 struct 字段(Performance、
|
||||||
@ -208,8 +206,6 @@ func processIncludes(cfg *Config, baseDir string, depth int, visited map[string]
|
|||||||
return fmt.Errorf("读取引入文件 %q 失败: %w", match, err)
|
return fmt.Errorf("读取引入文件 %q 失败: %w", match, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data = ExpandEnv(data)
|
|
||||||
|
|
||||||
var included Config
|
var included Config
|
||||||
if err := yaml.Unmarshal(data, &included); err != nil {
|
if err := yaml.Unmarshal(data, &included); err != nil {
|
||||||
return fmt.Errorf("解析引入文件 %q 失败: %w", match, err)
|
return fmt.Errorf("解析引入文件 %q 失败: %w", match, err)
|
||||||
|
|||||||
@ -142,9 +142,6 @@ func DefaultConfig() *Config {
|
|||||||
AuthRequest: AuthRequestConfig{
|
AuthRequest: AuthRequestConfig{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
CORS: CORSConfig{
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Compression: CompressionConfig{
|
Compression: CompressionConfig{
|
||||||
Type: "gzip",
|
Type: "gzip",
|
||||||
@ -202,14 +199,6 @@ func DefaultConfig() *Config {
|
|||||||
Path: DefaultPprofPath,
|
Path: DefaultPprofPath,
|
||||||
Allow: []string{"127.0.0.1"},
|
Allow: []string{"127.0.0.1"},
|
||||||
},
|
},
|
||||||
Healthz: HealthzConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Path: "/healthz",
|
|
||||||
},
|
|
||||||
Readyz: ReadyzConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Path: "/readyz",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
HTTP3: HTTP3Config{
|
HTTP3: HTTP3Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var envPattern = regexp.MustCompile(`\$\{([^}]+)\}`)
|
|
||||||
|
|
||||||
// ExpandEnv replaces ${VAR} patterns with environment variable values.
|
|
||||||
func ExpandEnv(data []byte) []byte {
|
|
||||||
return envPattern.ReplaceAllFunc(data, func(match []byte) []byte {
|
|
||||||
name := string(match[2 : len(match)-1])
|
|
||||||
if name == "" {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
if value, ok := os.LookupEnv(name); ok {
|
|
||||||
return []byte(value)
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExpandEnv(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
setup func(*testing.T)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single variable",
|
|
||||||
input: "${VAR}",
|
|
||||||
expected: "value",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("VAR", "value")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple variables",
|
|
||||||
input: "${HOST}:${PORT}",
|
|
||||||
expected: "localhost:8080",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("HOST", "localhost")
|
|
||||||
t.Setenv("PORT", "8080")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "variable with prefix and suffix",
|
|
||||||
input: "prefix_${VAR}_suffix",
|
|
||||||
expected: "prefix_value_suffix",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("VAR", "value")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing variable unchanged",
|
|
||||||
input: "${MISSING}",
|
|
||||||
expected: "${MISSING}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed existing and missing",
|
|
||||||
input: "${VAR}/${MISSING}",
|
|
||||||
expected: "value/${MISSING}",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("VAR", "value")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no variables",
|
|
||||||
input: "just a plain string",
|
|
||||||
expected: "just a plain string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty variable name",
|
|
||||||
input: "${}",
|
|
||||||
expected: "${}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "adjacent variables",
|
|
||||||
input: "${VAR1}${VAR2}",
|
|
||||||
expected: "value1value2",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("VAR1", "value1")
|
|
||||||
t.Setenv("VAR2", "value2")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "variable with empty value",
|
|
||||||
input: "${EMPTY_VAR}",
|
|
||||||
expected: "",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("EMPTY_VAR", "")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same variable multiple times",
|
|
||||||
input: "${VAR}-${VAR}-${VAR}",
|
|
||||||
expected: "val-val-val",
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("VAR", "val")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "full yaml-like input",
|
|
||||||
input: `server:
|
|
||||||
host: ${HOST}
|
|
||||||
port: ${PORT}
|
|
||||||
name: ${APP_NAME}`,
|
|
||||||
expected: `server:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: 9090
|
|
||||||
name: lolly`,
|
|
||||||
setup: func(t *testing.T) {
|
|
||||||
t.Setenv("HOST", "127.0.0.1")
|
|
||||||
t.Setenv("PORT", "9090")
|
|
||||||
t.Setenv("APP_NAME", "lolly")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.setup != nil {
|
|
||||||
tt.setup(t)
|
|
||||||
}
|
|
||||||
result := ExpandEnv([]byte(tt.input))
|
|
||||||
assert.Equal(t, tt.expected, string(result))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,22 +20,13 @@ package config
|
|||||||
// path: "/status"
|
// path: "/status"
|
||||||
// allow: ["127.0.0.1", "10.0.0.0/8"]
|
// allow: ["127.0.0.1", "10.0.0.0/8"]
|
||||||
type MonitoringConfig struct {
|
type MonitoringConfig struct {
|
||||||
|
// Status 状态端点配置
|
||||||
|
// 服务健康状态检查端点
|
||||||
Status StatusConfig `yaml:"status"`
|
Status StatusConfig `yaml:"status"`
|
||||||
|
|
||||||
|
// Pprof pprof 性能分析端点配置
|
||||||
|
// 用于收集 CPU、内存等性能数据,支持 PGO 优化
|
||||||
Pprof PprofConfig `yaml:"pprof"`
|
Pprof PprofConfig `yaml:"pprof"`
|
||||||
Healthz HealthzConfig `yaml:"healthz"`
|
|
||||||
Readyz ReadyzConfig `yaml:"readyz"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthzConfig configures the /healthz liveness probe endpoint.
|
|
||||||
type HealthzConfig struct {
|
|
||||||
Path string `yaml:"path"`
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadyzConfig configures the /readyz readiness probe endpoint.
|
|
||||||
type ReadyzConfig struct {
|
|
||||||
Path string `yaml:"path"`
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PprofConfig pprof 性能分析端点配置。
|
// PprofConfig pprof 性能分析端点配置。
|
||||||
|
|||||||
@ -41,18 +41,6 @@ type SecurityConfig struct {
|
|||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
AuthRequest AuthRequestConfig `yaml:"auth_request"`
|
AuthRequest AuthRequestConfig `yaml:"auth_request"`
|
||||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||||
CORS CORSConfig `yaml:"cors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORSConfig configures Cross-Origin Resource Sharing (CORS) headers.
|
|
||||||
type CORSConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
|
||||||
AllowedMethods []string `yaml:"allowed_methods"`
|
|
||||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
|
||||||
ExposeHeaders []string `yaml:"expose_headers"`
|
|
||||||
AllowCredentials bool `yaml:"allow_credentials"`
|
|
||||||
MaxAge int `yaml:"max_age"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessConfig IP 访问控制配置。
|
// AccessConfig IP 访问控制配置。
|
||||||
|
|||||||
@ -645,54 +645,26 @@ func validateSSL(s *SSLConfig) error {
|
|||||||
// 返回值:
|
// 返回值:
|
||||||
// - error: 验证失败时返回错误信息,成功返回 nil
|
// - error: 验证失败时返回错误信息,成功返回 nil
|
||||||
func validateSecurity(s *SecurityConfig) error {
|
func validateSecurity(s *SecurityConfig) error {
|
||||||
|
// 验证访问控制配置
|
||||||
if err := validateAccess(&s.Access); err != nil {
|
if err := validateAccess(&s.Access); err != nil {
|
||||||
return fmt.Errorf("access: %w", err)
|
return fmt.Errorf("access: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证认证配置
|
||||||
if err := validateAuth(&s.Auth); err != nil {
|
if err := validateAuth(&s.Auth); err != nil {
|
||||||
return fmt.Errorf("auth: %w", err)
|
return fmt.Errorf("auth: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证速率限制配置
|
||||||
if err := validateRateLimit(&s.RateLimit); err != nil {
|
if err := validateRateLimit(&s.RateLimit); err != nil {
|
||||||
return fmt.Errorf("rate_limit: %w", err)
|
return fmt.Errorf("rate_limit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证安全头部配置
|
||||||
if err := validateSecurityHeaders(&s.Headers); err != nil {
|
if err := validateSecurityHeaders(&s.Headers); err != nil {
|
||||||
return fmt.Errorf("headers: %w", err)
|
return fmt.Errorf("headers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateCORS(&s.CORS); err != nil {
|
|
||||||
return fmt.Errorf("cors: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCORS(c *CORSConfig) error {
|
|
||||||
if !c.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.AllowedOrigins) == 0 {
|
|
||||||
return errors.New("启用 CORS 时必须配置 allowed_origins")
|
|
||||||
}
|
|
||||||
|
|
||||||
hasWildcard := false
|
|
||||||
for _, o := range c.AllowedOrigins {
|
|
||||||
if o == "*" {
|
|
||||||
hasWildcard = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasWildcard && c.AllowCredentials {
|
|
||||||
return errors.New("allowed_origins 包含 \"*\" 时不能同时启用 allow_credentials(CORS 规范不允许)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.MaxAge < 0 {
|
|
||||||
return errors.New("max_age 不能为负数")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
package cors
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"rua.plus/lolly/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CORSConfig holds CORS middleware configuration.
|
|
||||||
type CORSConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
|
||||||
AllowedMethods []string `yaml:"allowed_methods"`
|
|
||||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
|
||||||
ExposeHeaders []string `yaml:"expose_headers"`
|
|
||||||
AllowCredentials bool `yaml:"allow_credentials"`
|
|
||||||
MaxAge int `yaml:"max_age"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORSMiddleware implements CORS (Cross-Origin Resource Sharing) handling.
|
|
||||||
type CORSMiddleware struct {
|
|
||||||
cfg *CORSConfig
|
|
||||||
wildcard bool
|
|
||||||
originSet map[string]struct{}
|
|
||||||
methodsVal []byte
|
|
||||||
headersVal []byte
|
|
||||||
exposeVal []byte
|
|
||||||
maxAgeVal []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ middleware.Middleware = (*CORSMiddleware)(nil)
|
|
||||||
|
|
||||||
// New creates a new CORS middleware from the given configuration.
|
|
||||||
func New(cfg *CORSConfig) *CORSMiddleware {
|
|
||||||
if cfg == nil {
|
|
||||||
return &CORSMiddleware{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.Enabled || len(cfg.AllowedOrigins) == 0 {
|
|
||||||
return &CORSMiddleware{cfg: cfg}
|
|
||||||
}
|
|
||||||
|
|
||||||
m := &CORSMiddleware{
|
|
||||||
cfg: cfg,
|
|
||||||
originSet: make(map[string]struct{}, len(cfg.AllowedOrigins)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, o := range cfg.AllowedOrigins {
|
|
||||||
if o == "*" {
|
|
||||||
m.wildcard = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.originSet[o] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.AllowedMethods) > 0 {
|
|
||||||
m.methodsVal = []byte(joinStrings(cfg.AllowedMethods))
|
|
||||||
}
|
|
||||||
if len(cfg.AllowedHeaders) > 0 {
|
|
||||||
m.headersVal = []byte(joinStrings(cfg.AllowedHeaders))
|
|
||||||
}
|
|
||||||
if len(cfg.ExposeHeaders) > 0 {
|
|
||||||
m.exposeVal = []byte(joinStrings(cfg.ExposeHeaders))
|
|
||||||
}
|
|
||||||
if cfg.MaxAge > 0 {
|
|
||||||
m.maxAgeVal = []byte(intToStr(cfg.MaxAge))
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the middleware name.
|
|
||||||
func (c *CORSMiddleware) Name() string { return "CORS" }
|
|
||||||
|
|
||||||
// Process implements the middleware.Middleware interface.
|
|
||||||
func (c *CORSMiddleware) Process(next fasthttp.RequestHandler) fasthttp.RequestHandler {
|
|
||||||
return func(ctx *fasthttp.RequestCtx) {
|
|
||||||
if c.cfg == nil || !c.cfg.Enabled || len(c.cfg.AllowedOrigins) == 0 {
|
|
||||||
next(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
origin := ctx.Request.Header.Peek("Origin")
|
|
||||||
if len(origin) == 0 {
|
|
||||||
next(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.matchOrigin(origin) {
|
|
||||||
next(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(ctx.Request.Header.Method(), []byte("OPTIONS")) {
|
|
||||||
c.handlePreflight(ctx, origin)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next(ctx)
|
|
||||||
c.setActualHeaders(ctx, origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CORSMiddleware) matchOrigin(origin []byte) bool {
|
|
||||||
if c.wildcard {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, ok := c.originSet[string(origin)]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CORSMiddleware) handlePreflight(ctx *fasthttp.RequestCtx, origin []byte) {
|
|
||||||
h := &ctx.Response.Header
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Origin"), origin)
|
|
||||||
|
|
||||||
if len(c.methodsVal) > 0 {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Methods"), c.methodsVal)
|
|
||||||
}
|
|
||||||
if len(c.headersVal) > 0 {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Headers"), c.headersVal)
|
|
||||||
}
|
|
||||||
if c.cfg.MaxAge > 0 {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Max-Age"), c.maxAgeVal)
|
|
||||||
}
|
|
||||||
if c.cfg.AllowCredentials {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Credentials"), []byte("true"))
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CORSMiddleware) setActualHeaders(ctx *fasthttp.RequestCtx, origin []byte) {
|
|
||||||
h := &ctx.Response.Header
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Origin"), origin)
|
|
||||||
|
|
||||||
if len(c.exposeVal) > 0 {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Expose-Headers"), c.exposeVal)
|
|
||||||
}
|
|
||||||
if c.cfg.AllowCredentials {
|
|
||||||
h.SetBytesKV([]byte("Access-Control-Allow-Credentials"), []byte("true"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinStrings(ss []string) string {
|
|
||||||
switch len(ss) {
|
|
||||||
case 0:
|
|
||||||
return ""
|
|
||||||
case 1:
|
|
||||||
return ss[0]
|
|
||||||
default:
|
|
||||||
var buf []byte
|
|
||||||
for i, s := range ss {
|
|
||||||
if i > 0 {
|
|
||||||
buf = append(buf, ',')
|
|
||||||
}
|
|
||||||
buf = append(buf, s...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intToStr(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 12)
|
|
||||||
neg := false
|
|
||||||
if n < 0 {
|
|
||||||
neg = true
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for n > 0 {
|
|
||||||
buf = append(buf, byte('0'+n%10))
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append(buf, '-')
|
|
||||||
}
|
|
||||||
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
buf[i], buf[j] = buf[j], buf[i]
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
package cors
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestHandler() (*fasthttp.RequestCtx, fasthttp.RequestHandler) {
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
return ctx, func(ctx *fasthttp.RequestCtx) {
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
ctx.SetBodyString("ok")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDisabled_PassesThrough(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{Enabled: false}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, "ok", string(ctx.Response.Body()))
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilConfig_PassesThrough(t *testing.T) {
|
|
||||||
m := New(nil)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoOrigin_PassesThrough(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNonMatchingOrigin_NoCORSHeaders(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://evil.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatchingOrigin_SetsCORSHeaders(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
ExposeHeaders: []string{"X-Custom", "X-Another"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, "https://example.com", string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
assert.Equal(t, "true", string(ctx.Response.Header.Peek("Access-Control-Allow-Credentials")))
|
|
||||||
assert.Equal(t, "X-Custom,X-Another", string(ctx.Response.Header.Peek("Access-Control-Expose-Headers")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreflight_Returns204(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT"},
|
|
||||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
|
||||||
MaxAge: 3600,
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.SetMethod("OPTIONS")
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusNoContent, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, "https://example.com", string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
assert.Equal(t, "GET,POST,PUT", string(ctx.Response.Header.Peek("Access-Control-Allow-Methods")))
|
|
||||||
assert.Equal(t, "Content-Type,Authorization", string(ctx.Response.Header.Peek("Access-Control-Allow-Headers")))
|
|
||||||
assert.Equal(t, "3600", string(ctx.Response.Header.Peek("Access-Control-Max-Age")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWildcardOrigin_MatchesAny(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"*"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://anything.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, "https://anything.com", string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowCredentials_SetsHeader(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, "true", string(ctx.Response.Header.Peek("Access-Control-Allow-Credentials")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxAge_SetsHeaderWhenPositive(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowedMethods: []string{"GET"},
|
|
||||||
MaxAge: 7200,
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.SetMethod("OPTIONS")
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, "7200", string(ctx.Response.Header.Peek("Access-Control-Max-Age")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxAge_NotSetWhenZero(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowedMethods: []string{"GET"},
|
|
||||||
MaxAge: 0,
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.SetMethod("OPTIONS")
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Max-Age")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExposeHeaders_OnActualRequest(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
ExposeHeaders: []string{"X-Total-Count", "X-Page"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, "X-Total-Count,X-Page", string(ctx.Response.Header.Peek("Access-Control-Expose-Headers")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipleOrigins_OnlyMatchingEchoedBack(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://a.com", "https://b.com", "https://c.com"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
|
|
||||||
for _, origin := range []string{"https://a.com", "https://b.com", "https://c.com"} {
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", origin)
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, origin, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://evil.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyAllowedOrigins_PassesThrough(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode())
|
|
||||||
assert.Empty(t, string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreflight_WithCredentials(t *testing.T) {
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowedMethods: []string{"GET", "POST"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx, next := newTestHandler()
|
|
||||||
ctx.Request.Header.SetMethod("OPTIONS")
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(next)
|
|
||||||
handler(ctx)
|
|
||||||
assert.Equal(t, fasthttp.StatusNoContent, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, "true", string(ctx.Response.Header.Peek("Access-Control-Allow-Credentials")))
|
|
||||||
assert.Equal(t, "https://example.com", string(ctx.Response.Header.Peek("Access-Control-Allow-Origin")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
m := New(nil)
|
|
||||||
assert.Equal(t, "CORS", m.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreflight_DoesNotCallNext(t *testing.T) {
|
|
||||||
called := false
|
|
||||||
cfg := &CORSConfig{
|
|
||||||
Enabled: true,
|
|
||||||
AllowedOrigins: []string{"https://example.com"},
|
|
||||||
AllowedMethods: []string{"GET"},
|
|
||||||
}
|
|
||||||
m := New(cfg)
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
ctx.Request.Header.SetMethod("OPTIONS")
|
|
||||||
ctx.Request.Header.Set("Origin", "https://example.com")
|
|
||||||
handler := m.Process(func(ctx *fasthttp.RequestCtx) {
|
|
||||||
called = true
|
|
||||||
})
|
|
||||||
handler(ctx)
|
|
||||||
assert.False(t, called, "next handler should not be called for preflight requests")
|
|
||||||
assert.Equal(t, fasthttp.StatusNoContent, ctx.Response.StatusCode())
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
package requestid
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
|
|
||||||
"rua.plus/lolly/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
var requestIDHeader = []byte("X-Request-ID")
|
|
||||||
|
|
||||||
// RequestIDMiddleware generates or propagates X-Request-ID for request tracing.
|
|
||||||
type RequestIDMiddleware struct{}
|
|
||||||
|
|
||||||
var _ middleware.Middleware = (*RequestIDMiddleware)(nil)
|
|
||||||
|
|
||||||
// New creates a new Request-ID middleware.
|
|
||||||
func New() *RequestIDMiddleware {
|
|
||||||
return &RequestIDMiddleware{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the middleware name.
|
|
||||||
func (m *RequestIDMiddleware) Name() string { return "request_id" }
|
|
||||||
|
|
||||||
// Process implements the middleware.Middleware interface.
|
|
||||||
func (m *RequestIDMiddleware) Process(next fasthttp.RequestHandler) fasthttp.RequestHandler {
|
|
||||||
return func(ctx *fasthttp.RequestCtx) {
|
|
||||||
var id string
|
|
||||||
|
|
||||||
incoming := ctx.Request.Header.PeekBytes(requestIDHeader)
|
|
||||||
if len(incoming) > 0 {
|
|
||||||
trimmed := bytes.TrimSpace(incoming)
|
|
||||||
if len(trimmed) > 0 {
|
|
||||||
id = string(trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == "" {
|
|
||||||
id = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetUserValue("request_id", id)
|
|
||||||
ctx.Response.Header.SetBytesKV(requestIDHeader, []byte(id))
|
|
||||||
|
|
||||||
next(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRequestID extracts the request ID from the request context.
|
|
||||||
func GetRequestID(ctx *fasthttp.RequestCtx) string {
|
|
||||||
if v := ctx.UserValue("request_id"); v != nil {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
package requestid
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRequestID_GeneratesUUID(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
var capturedID string
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
capturedID = GetRequestID(ctx)
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, capturedID, "request ID should be generated")
|
|
||||||
_, err := uuid.Parse(capturedID)
|
|
||||||
assert.NoError(t, err, "generated ID should be valid UUID")
|
|
||||||
|
|
||||||
assert.Equal(t, capturedID, string(ctx.Response.Header.Peek("X-Request-ID")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_ReusesIncoming(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
incomingID := "existing-id-12345"
|
|
||||||
var capturedID string
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
capturedID = GetRequestID(ctx)
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
ctx.Request.Header.Set("X-Request-ID", incomingID)
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, incomingID, capturedID)
|
|
||||||
assert.Equal(t, incomingID, string(ctx.Response.Header.Peek("X-Request-ID")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_EmptyHeaderGeneratesNew(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
var capturedID string
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
capturedID = GetRequestID(ctx)
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
ctx.Request.Header.Set("X-Request-ID", " ")
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, capturedID, "empty header should generate new UUID")
|
|
||||||
_, err := uuid.Parse(capturedID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_UserValueAccessible(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
val := ctx.UserValue("request_id")
|
|
||||||
assert.NotNil(t, val)
|
|
||||||
s, ok := val.(string)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, s)
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_ResponseHeaderSet(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
|
|
||||||
respHeader := string(ctx.Response.Header.Peek("X-Request-ID"))
|
|
||||||
assert.NotEmpty(t, respHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_GeneratedUUIDValid(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
|
|
||||||
next := func(ctx *fasthttp.RequestCtx) {
|
|
||||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := m.Process(next)
|
|
||||||
|
|
||||||
for range 10 {
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
ctx.Request.SetRequestURI("/test")
|
|
||||||
|
|
||||||
handler(ctx)
|
|
||||||
|
|
||||||
respHeader := string(ctx.Response.Header.Peek("X-Request-ID"))
|
|
||||||
_, err := uuid.Parse(respHeader)
|
|
||||||
assert.NoError(t, err, "generated UUID should be valid: %s", respHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_Name(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
assert.Equal(t, "request_id", m.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRequestID_Empty(t *testing.T) {
|
|
||||||
ctx := &fasthttp.RequestCtx{}
|
|
||||||
assert.Equal(t, "", GetRequestID(ctx))
|
|
||||||
}
|
|
||||||
@ -50,7 +50,6 @@ func (p *Proxy) modifyRequestHeaders(ctx *fasthttp.RequestCtx, target *loadbalan
|
|||||||
}
|
}
|
||||||
|
|
||||||
SetForwardedHeaders(headers, fh, true, setHost, setProto)
|
SetForwardedHeaders(headers, fh, true, setHost, setProto)
|
||||||
SetRequestIDHeader(headers, ctx)
|
|
||||||
|
|
||||||
// 从配置设置自定义请求头(支持变量展开)
|
// 从配置设置自定义请求头(支持变量展开)
|
||||||
if p.config.Headers.SetRequest != nil {
|
if p.config.Headers.SetRequest != nil {
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"rua.plus/lolly/internal/middleware/requestid"
|
|
||||||
"rua.plus/lolly/internal/netutil"
|
"rua.plus/lolly/internal/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -108,13 +107,6 @@ func SetForwardedHeaders(headers *fasthttp.RequestHeader, fh ForwardedHeaders, a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRequestIDHeader propagates X-Request-ID from the request context to upstream headers.
|
|
||||||
func SetRequestIDHeader(headers *fasthttp.RequestHeader, ctx *fasthttp.RequestCtx) {
|
|
||||||
if id := requestid.GetRequestID(ctx); id != "" {
|
|
||||||
headers.Set("X-Request-ID", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteForwardedHeaders 将 X-Forwarded 头写入到 strings.Builder。
|
// WriteForwardedHeaders 将 X-Forwarded 头写入到 strings.Builder。
|
||||||
// 用于 WebSocket 升级请求构建。
|
// 用于 WebSocket 升级请求构建。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import "github.com/valyala/fasthttp"
|
|
||||||
|
|
||||||
// HealthzHandler returns a liveness probe that always responds 200 {"status":"ok"}.
|
|
||||||
func HealthzHandler(ctx *fasthttp.RequestCtx) {
|
|
||||||
ctx.SetContentType("application/json")
|
|
||||||
ctx.SetStatusCode(200)
|
|
||||||
ctx.SetBodyString(`{"status":"ok"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReadyzHandler creates a readiness probe using the provided checker function.
|
|
||||||
func NewReadyzHandler(checker func() (bool, []string)) fasthttp.RequestHandler {
|
|
||||||
return func(ctx *fasthttp.RequestCtx) {
|
|
||||||
ctx.SetContentType("application/json")
|
|
||||||
ready, reasons := checker()
|
|
||||||
if ready {
|
|
||||||
ctx.SetStatusCode(200)
|
|
||||||
ctx.SetBodyString(`{"status":"ready"}`)
|
|
||||||
} else {
|
|
||||||
ctx.SetStatusCode(503)
|
|
||||||
ctx.SetBodyString(buildReasonsJSON(reasons))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildReasonsJSON(reasons []string) string {
|
|
||||||
if len(reasons) == 0 {
|
|
||||||
return `{"status":"not ready"}`
|
|
||||||
}
|
|
||||||
var buf []byte
|
|
||||||
buf = append(buf, `{"status":"not ready","reasons":[`...)
|
|
||||||
for i, r := range reasons {
|
|
||||||
if i > 0 {
|
|
||||||
buf = append(buf, ',')
|
|
||||||
}
|
|
||||||
buf = append(buf, '"')
|
|
||||||
buf = append(buf, r...)
|
|
||||||
buf = append(buf, '"')
|
|
||||||
}
|
|
||||||
buf = append(buf, "]}"...)
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultReadyzChecker returns a readiness checker for the Server.
|
|
||||||
func DefaultReadyzChecker(s *Server) func() (bool, []string) {
|
|
||||||
return func() (bool, []string) {
|
|
||||||
if !s.running.Load() {
|
|
||||||
return false, []string{"server not running"}
|
|
||||||
}
|
|
||||||
s.proxiesMu.RLock()
|
|
||||||
n := len(s.proxies)
|
|
||||||
s.proxiesMu.RUnlock()
|
|
||||||
if n == 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHealthzHandler(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
var ctx fasthttp.RequestCtx
|
|
||||||
HealthzHandler(&ctx)
|
|
||||||
assert.Equal(t, 200, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, "application/json", string(ctx.Response.Header.ContentType()))
|
|
||||||
assert.Equal(t, `{"status":"ok"}`, string(ctx.Response.Body()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthzHandler_ValidJSON(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
var ctx fasthttp.RequestCtx
|
|
||||||
HealthzHandler(&ctx)
|
|
||||||
var result map[string]string
|
|
||||||
err := json.Unmarshal(ctx.Response.Body(), &result)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "ok", result["status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadyzHandler_Ready(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
handler := NewReadyzHandler(func() (bool, []string) {
|
|
||||||
return true, nil
|
|
||||||
})
|
|
||||||
var ctx fasthttp.RequestCtx
|
|
||||||
handler(&ctx)
|
|
||||||
assert.Equal(t, 200, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, `{"status":"ready"}`, string(ctx.Response.Body()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadyzHandler_NotReady(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
handler := NewReadyzHandler(func() (bool, []string) {
|
|
||||||
return false, []string{"test reason"}
|
|
||||||
})
|
|
||||||
var ctx fasthttp.RequestCtx
|
|
||||||
handler(&ctx)
|
|
||||||
assert.Equal(t, 503, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, `{"status":"not ready","reasons":["test reason"]}`, string(ctx.Response.Body()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadyzHandler_NotReady_NoReasons(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
handler := NewReadyzHandler(func() (bool, []string) {
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
var ctx fasthttp.RequestCtx
|
|
||||||
handler(&ctx)
|
|
||||||
assert.Equal(t, 503, ctx.Response.StatusCode())
|
|
||||||
assert.Equal(t, `{"status":"not ready"}`, string(ctx.Response.Body()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildReasonsJSON_MultipleReasons(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
result := buildReasonsJSON([]string{"reason A", "reason B", "reason C"})
|
|
||||||
var parsed map[string]any
|
|
||||||
err := json.Unmarshal([]byte(result), &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "not ready", parsed["status"])
|
|
||||||
reasons, ok := parsed["reasons"].([]any)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 3, len(reasons))
|
|
||||||
assert.Equal(t, "reason A", reasons[0])
|
|
||||||
assert.Equal(t, "reason B", reasons[1])
|
|
||||||
assert.Equal(t, "reason C", reasons[2])
|
|
||||||
}
|
|
||||||
@ -14,9 +14,7 @@ import (
|
|||||||
"rua.plus/lolly/internal/middleware"
|
"rua.plus/lolly/internal/middleware"
|
||||||
"rua.plus/lolly/internal/middleware/bodylimit"
|
"rua.plus/lolly/internal/middleware/bodylimit"
|
||||||
"rua.plus/lolly/internal/middleware/compression"
|
"rua.plus/lolly/internal/middleware/compression"
|
||||||
"rua.plus/lolly/internal/middleware/cors"
|
|
||||||
"rua.plus/lolly/internal/middleware/errorintercept"
|
"rua.plus/lolly/internal/middleware/errorintercept"
|
||||||
"rua.plus/lolly/internal/middleware/requestid"
|
|
||||||
"rua.plus/lolly/internal/middleware/rewrite"
|
"rua.plus/lolly/internal/middleware/rewrite"
|
||||||
"rua.plus/lolly/internal/middleware/security"
|
"rua.plus/lolly/internal/middleware/security"
|
||||||
)
|
)
|
||||||
@ -40,9 +38,6 @@ import (
|
|||||||
func (s *Server) buildMiddlewareChain(serverCfg *config.ServerConfig) (*middleware.Chain, error) {
|
func (s *Server) buildMiddlewareChain(serverCfg *config.ServerConfig) (*middleware.Chain, error) {
|
||||||
var middlewares []middleware.Middleware
|
var middlewares []middleware.Middleware
|
||||||
|
|
||||||
// 0. Request-ID (最先执行,确保后续中间件和日志可使用 $request_id)
|
|
||||||
middlewares = append(middlewares, requestid.New())
|
|
||||||
|
|
||||||
// 1. AccessLog (已集成)
|
// 1. AccessLog (已集成)
|
||||||
middlewares = append(middlewares, s.accessLogMiddleware)
|
middlewares = append(middlewares, s.accessLogMiddleware)
|
||||||
|
|
||||||
@ -147,19 +142,6 @@ func (s *Server) buildMiddlewareChain(serverCfg *config.ServerConfig) (*middlewa
|
|||||||
middlewares = append(middlewares, headers)
|
middlewares = append(middlewares, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7.5 CORS (跨域资源共享)
|
|
||||||
if serverCfg.Security.CORS.Enabled {
|
|
||||||
middlewares = append(middlewares, cors.New(&cors.CORSConfig{
|
|
||||||
Enabled: serverCfg.Security.CORS.Enabled,
|
|
||||||
AllowedOrigins: serverCfg.Security.CORS.AllowedOrigins,
|
|
||||||
AllowedMethods: serverCfg.Security.CORS.AllowedMethods,
|
|
||||||
AllowedHeaders: serverCfg.Security.CORS.AllowedHeaders,
|
|
||||||
ExposeHeaders: serverCfg.Security.CORS.ExposeHeaders,
|
|
||||||
AllowCredentials: serverCfg.Security.CORS.AllowCredentials,
|
|
||||||
MaxAge: serverCfg.Security.CORS.MaxAge,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. ErrorIntercept (错误页面拦截)
|
// 8. ErrorIntercept (错误页面拦截)
|
||||||
// 如果配置了错误页面,添加错误拦截中间件
|
// 如果配置了错误页面,添加错误拦截中间件
|
||||||
if s.errorPageManager != nil && s.errorPageManager.IsConfigured() {
|
if s.errorPageManager != nil && s.errorPageManager.IsConfigured() {
|
||||||
|
|||||||
@ -492,31 +492,6 @@ func (s *Server) startSingleMode() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.config.Monitoring.Healthz.Enabled {
|
|
||||||
hzPath := s.config.Monitoring.Healthz.Path
|
|
||||||
if hzPath == "" {
|
|
||||||
hzPath = "/healthz"
|
|
||||||
}
|
|
||||||
if regErr := s.locationEngine.AddExact(hzPath, HealthzHandler, false); regErr != nil {
|
|
||||||
if err := s.handleRegistrationError("healthz", hzPath, regErr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.config.Monitoring.Readyz.Enabled {
|
|
||||||
rzPath := s.config.Monitoring.Readyz.Path
|
|
||||||
if rzPath == "" {
|
|
||||||
rzPath = "/readyz"
|
|
||||||
}
|
|
||||||
readyzHandler := NewReadyzHandler(DefaultReadyzChecker(s))
|
|
||||||
if regErr := s.locationEngine.AddExact(rzPath, readyzHandler, false); regErr != nil {
|
|
||||||
if err := s.handleRegistrationError("readyz", rzPath, regErr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.config.Monitoring.Pprof.Enabled {
|
if s.config.Monitoring.Pprof.Enabled {
|
||||||
pprofHandler, err := NewPprofHandler(&s.config.Monitoring.Pprof)
|
pprofHandler, err := NewPprofHandler(&s.config.Monitoring.Pprof)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -828,25 +803,6 @@ func (s *Server) registerMonitoringEndpoints(router *handler.Router, serverCfg *
|
|||||||
router.POST(purgeHandler.Path(), purgeHandler.ServeHTTP)
|
router.POST(purgeHandler.Path(), purgeHandler.ServeHTTP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isDefault {
|
|
||||||
if s.config.Monitoring.Healthz.Enabled {
|
|
||||||
hzPath := s.config.Monitoring.Healthz.Path
|
|
||||||
if hzPath == "" {
|
|
||||||
hzPath = "/healthz"
|
|
||||||
}
|
|
||||||
router.GET(hzPath, HealthzHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.config.Monitoring.Readyz.Enabled {
|
|
||||||
rzPath := s.config.Monitoring.Readyz.Path
|
|
||||||
if rzPath == "" {
|
|
||||||
rzPath = "/readyz"
|
|
||||||
}
|
|
||||||
readyzHandler := NewReadyzHandler(DefaultReadyzChecker(s))
|
|
||||||
router.GET(rzPath, readyzHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapHandler 应用中间件链、连接池包装和统计追踪。
|
// wrapHandler 应用中间件链、连接池包装和统计追踪。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user