From 9495a548e67e4f368fd92ae1ac7eb0c47c993f9e Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 8 Apr 2026 11:29:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy,server,logging):=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=8F=98=E9=87=8F=E7=B3=BB=E7=BB=9F=E5=92=8C=20DNS=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变量系统集成: - logging: 访问日志使用 variable 包展开模板 - rewrite: 重写规则支持变量展开 - proxy: 请求/响应头设置支持变量展开 DNS 解析器集成: - app: 创建并启用 Resolver - server: SetResolver/GetResolver 方法传递给 proxy - proxy: SetResolver/Start 方法,后台 DNS 刷新协程 - proxy_dns.go: DNS 刷新逻辑和 IP 直连支持 新增集成测试: - internal/integration/variable_test.go - internal/integration/resolver_test.go 文档更新: - docs/config-reference.md 配置参考文档 Co-Authored-By: Claude Opus 4.6 --- docs/config-reference.md | 230 +++++++++++++++++ internal/app/app.go | 18 ++ internal/integration/resolver_test.go | 224 +++++++++++++++++ internal/integration/variable_test.go | 331 +++++++++++++++++++++++++ internal/logging/logging.go | 40 +-- internal/middleware/rewrite/rewrite.go | 6 + internal/proxy/proxy.go | 27 +- internal/proxy/proxy_dns.go | 182 ++++++++++++++ internal/server/server.go | 22 ++ 9 files changed, 1057 insertions(+), 23 deletions(-) create mode 100644 docs/config-reference.md create mode 100644 internal/integration/resolver_test.go create mode 100644 internal/integration/variable_test.go create mode 100644 internal/proxy/proxy_dns.go diff --git a/docs/config-reference.md b/docs/config-reference.md new file mode 100644 index 0000000..289a4a5 --- /dev/null +++ b/docs/config-reference.md @@ -0,0 +1,230 @@ +# 配置参考文档 + +## 目录 + +- [变量系统](#变量系统) +- [DNS 解析器](#dns-解析器) +- [访问日志格式](#访问日志格式) + +--- + +## 变量系统 + +Lolly 支持 nginx 风格的变量系统,可用于访问日志格式、代理请求头和 URL 重写规则。 + +### 内置变量 + +| 变量名 | 说明 | 示例值 | +|--------|------|--------| +| `$host` | 请求的主机名(Host 头) | `example.com` | +| `$remote_addr` | 客户端 IP 地址 | `192.168.1.1` | +| `$remote_port` | 客户端端口 | `54321` | +| `$request_uri` | 原始请求 URI(包含查询参数) | `/api/users?page=1` | +| `$uri` | 解码后的 URI 路径 | `/api/users` | +| `$args` | 查询参数字符串 | `page=1&limit=10` | +| `$request_method` | HTTP 请求方法 | `GET`, `POST` | +| `$scheme` | 协议 | `http`, `https` | +| `$server_name` | 服务器名称 | `localhost` | +| `$server_port` | 服务器端口 | `8080` | +| `$status` | HTTP 响应状态码 | `200`, `404` | +| `$body_bytes_sent` | 发送的响应体字节数 | `1024` | +| `$request_time` | 请求处理时间(秒) | `0.050` | +| `$time_local` | 本地时间 | `08/Apr/2026:11:04:58 +0800` | +| `$time_iso8601` | ISO8601 格式时间 | `2026-04-08T11:04:58+08:00` | +| `$request_id` | 唯一请求标识符 | `uuid` | + +### 动态 HTTP 头变量 + +以 `$http_` 开头的变量用于获取 HTTP 请求头值: + +- `$http_user_agent` - User-Agent 头 +- `$http_referer` - Referer 头 +- `$http_x_forwarded_for` - X-Forwarded-For 头 +- 其他任意请求头:`$http_header_name` + +### 变量格式 + +支持两种格式: + +1. **简单格式**: `$var` + ``` + $host $uri + ``` + +2. **花括号格式**: `${var}` + ``` + ${host}:8080 + ${scheme}://${host}${uri} + ``` + +### 在代理请求头中使用变量 + +```yaml +proxy: + - path: /api + targets: + - url: http://backend:8080 + headers: + set_request: + X-Real-IP: "$remote_addr" + X-Forwarded-Host: "$host" + X-Request-ID: "$request_id" +``` + +### 在访问日志中使用变量 + +```yaml +logging: + access: + format: '$remote_addr - $remote_user [$time_local] "$request_method $uri $scheme" $status $body_bytes_sent' +``` + +### 自定义变量 + +```yaml +variables: + set: + app_name: "lolly" + version: "1.0.0" + request_id: true # 自动生成唯一请求 ID +``` + +--- + +## DNS 解析器 + +Lolly 内置 DNS 解析器,支持动态解析后端服务域名。 + +### 配置选项 + +```yaml +resolver: + enabled: true # 是否启用 + addresses: # DNS 服务器地址列表 + - "8.8.8.8:53" + - "8.8.4.4:53" + valid: 30s # 缓存有效期(TTL) + timeout: 5s # DNS 查询超时 + ipv4: true # 查询 IPv4 地址 + ipv6: false # 查询 IPv6 地址 + cache_size: 1024 # 缓存最大条目数 +``` + +### 功能特性 + +- **DNS 缓存**: 按 TTL 缓存解析结果,减少 DNS 查询延迟 +- **后台刷新**: 自动在 TTL/2 时刷新缓存,避免过期 +- **故障转移**: 解析失败时使用缓存 IP 继续服务 +- **健康检查**: 首次解析失败标记目标不健康 + +### 使用场景 + +当后端目标使用域名时,DNS 解析器自动生效: + +```yaml +proxy: + - path: /api + targets: + - url: http://backend.example.com:8080 # 使用域名 + weight: 1 +``` + +### 监控指标 + +通过状态端点获取 DNS 解析统计: + +- `CacheHits` - 缓存命中次数 +- `CacheMisses` - 缓存未命中次数 +- `CacheEntries` - 当前缓存条目数 +- `ResolveErrors` - 解析错误次数 +- `AverageLatency` - 平均解析延迟 + +--- + +## 访问日志格式 + +### nginx 兼容格式 + +Lolly 默认提供 nginx 兼容的访问日志格式: + +```yaml +logging: + access: + format: '$remote_addr - $remote_user [$time] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"' +``` + +示例输出: +``` +192.168.1.1 - - [08/Apr/2026:11:04:58 +0800] "GET /api/users HTTP/1.1" 200 1024 "-" "Mozilla/5.0" +``` + +### JSON 格式 + +设置格式为 `json` 输出结构化日志: + +```yaml +logging: + access: + format: 'json' +``` + +示例输出: +```json +{ + "remote_addr": "192.168.1.1", + "request": "GET /api/users HTTP/1.1", + "status": 200, + "body_bytes_sent": 1024, + "http_user_agent": "Mozilla/5.0" +} +``` + +### 自定义格式 + +使用变量创建自定义格式: + +```yaml +logging: + access: + format: '$remote_addr $request_method $uri $status $request_time' +``` + +--- + +## 完整配置示例 + +```yaml +server: + listen: ":8080" + name: "localhost" + + proxy: + - path: /api + targets: + - url: http://backend.example.com:8080 + headers: + set_request: + X-Real-IP: "$remote_addr" + X-Forwarded-Host: "$host" + X-Request-ID: "$request_id" + +resolver: + enabled: true + addresses: + - "8.8.8.8:53" + valid: 30s + timeout: 5s + +variables: + set: + app_name: "lolly" + request_id: true + +logging: + access: + format: '$remote_addr - $remote_user [$time_local] "$request_method $uri $scheme" $status $body_bytes_sent' + path: "/var/log/lolly/access.log" + error: + level: "info" + path: "/var/log/lolly/error.log" +``` diff --git a/internal/app/app.go b/internal/app/app.go index 5e6dbab..2e5a688 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,6 +28,7 @@ import ( "rua.plus/lolly/internal/config" "rua.plus/lolly/internal/http3" "rua.plus/lolly/internal/logging" + "rua.plus/lolly/internal/resolver" "rua.plus/lolly/internal/server" "rua.plus/lolly/internal/stream" ) @@ -88,6 +89,9 @@ type App struct { // logger 应用日志管理器 logger *logging.AppLogger + + // resolver DNS 解析器(可选) + resv resolver.Resolver } // NewApp 创建应用程序。 @@ -178,9 +182,23 @@ func (a *App) Run() int { a.logger.LogStartup("配置加载成功", map[string]string{"config_path": a.cfgPath}) a.logger.LogStartup("监听地址", map[string]string{"listen": a.cfg.Server.Listen}) + // 创建 DNS 解析器(如果启用) + if a.cfg.Resolver.Enabled { + a.resv = resolver.New(&a.cfg.Resolver) + a.logger.LogStartup("DNS 解析器已启用", map[string]string{ + "addresses": fmt.Sprintf("%v", a.cfg.Resolver.Addresses), + "ttl": a.cfg.Resolver.TTL().String(), + }) + } + // 创建 HTTP 服务器 a.srv = server.New(a.cfg) + // 设置 DNS 解析器到服务器 + if a.resv != nil { + a.srv.SetResolver(a.resv) + } + // 如果有继承的监听器,设置到服务器 if len(a.listeners) > 0 { a.srv.SetListeners(a.listeners) diff --git a/internal/integration/resolver_test.go b/internal/integration/resolver_test.go new file mode 100644 index 0000000..fdb6ca9 --- /dev/null +++ b/internal/integration/resolver_test.go @@ -0,0 +1,224 @@ +// resolver_integration_test.go - DNS 解析器集成测试 +// +// 测试 DNS 解析器与代理的集成 +// +// 作者:xfy +package integration + +import ( + "context" + "testing" + "time" + + "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/resolver" +) + +// TestResolverBasicLookup 测试基本 DNS 解析功能 +func TestResolverBasicLookup(t *testing.T) { + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"8.8.8.8:53"}, + Valid: 30 * time.Second, + Timeout: 5 * time.Second, + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 测试解析一个已知域名 + ips, err := r.LookupHost(ctx, "dns.google") + if err != nil { + t.Skipf("Skipping DNS test (network unavailable): %v", err) + _ = r.Stop() + return + } + + if len(ips) == 0 { + t.Error("expected at least one IP for dns.google") + } + + _ = r.Stop() +} + +// TestResolverCache 测试 DNS 缓存功能 +func TestResolverCache(t *testing.T) { + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"8.8.8.8:53"}, + Valid: 30 * time.Second, + Timeout: 5 * time.Second, + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + ctx := context.Background() + + // 第一次查询(缓存未命中) + start := time.Now() + ips1, err := r.LookupHostWithCache(ctx, "dns.google") + if err != nil { + t.Skipf("Skipping DNS test (network unavailable): %v", err) + _ = r.Stop() + return + } + duration1 := time.Since(start) + + // 第二次查询(应该命中缓存) + start = time.Now() + ips2, err := r.LookupHostWithCache(ctx, "dns.google") + if err != nil { + t.Errorf("second lookup failed: %v", err) + _ = r.Stop() + return + } + duration2 := time.Since(start) + + // 验证缓存命中(第二次应该更快) + if duration2 > duration1 { + t.Logf("Warning: cached lookup (%v) slower than uncached (%v)", duration2, duration1) + } + + // 验证返回的 IP 相同 + if len(ips1) != len(ips2) { + t.Errorf("cached result different: got %d IPs, expected %d", len(ips2), len(ips1)) + } + + // 检查统计信息 + stats := r.Stats() + if stats.CacheHits < 1 { + t.Error("expected at least 1 cache hit") + } + if stats.CacheMisses < 1 { + t.Error("expected at least 1 cache miss") + } + + _ = r.Stop() +} + +// TestResolverTimeout 测试 DNS 查询超时 +func TestResolverTimeout(t *testing.T) { + // 使用一个不可达的地址 + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"192.0.2.1:53"}, // TEST-NET-1,不会响应 + Valid: 30 * time.Second, + Timeout: 1 * time.Second, // 短超时 + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + ctx := context.Background() + + start := time.Now() + _, err := r.LookupHost(ctx, "example.com") + elapsed := time.Since(start) + + // 注意:在某些网络环境下,这个地址可能会被防火墙快速拒绝 + // 所以我们只验证超时时间是否合理,不要求一定返回错误 + if err == nil { + t.Log("Warning: expected timeout error but got success (network may have different behavior)") + } + + // 验证超时时间不超过合理范围(允许一些偏差) + if elapsed > 3*time.Second { + t.Errorf("timeout took too long: %v", elapsed) + } + + _ = r.Stop() +} + +// TestResolverNXDOMAIN 测试 NXDOMAIN 错误处理 +func TestResolverNXDOMAIN(t *testing.T) { + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"8.8.8.8:53"}, + Valid: 30 * time.Second, + Timeout: 5 * time.Second, + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + ctx := context.Background() + + // 查询一个不存在的域名 + _, err := r.LookupHost(ctx, "this-should-not-exist-12345.example") + // 注意:某些 DNS 服务器可能配置为返回特定响应而不是 NXDOMAIN + // 这里我们只记录结果,不强制要求错误 + if err == nil { + t.Log("Warning: no error for non-existent domain (DNS server may have different behavior)") + } + + _ = r.Stop() +} + +// TestResolverStats 测试解析器统计信息 +func TestResolverStats(t *testing.T) { + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"8.8.8.8:53"}, + Valid: 30 * time.Second, + Timeout: 5 * time.Second, + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + // 初始状态 + stats := r.Stats() + if stats.CacheEntries != 0 { + t.Errorf("expected 0 cache entries initially, got %d", stats.CacheEntries) + } + + ctx := context.Background() + + // 执行几次查询 + _, _ = r.LookupHostWithCache(ctx, "dns.google") + _, _ = r.LookupHostWithCache(ctx, "cloudflare.com") + + // 验证缓存条目 + stats = r.Stats() + if stats.CacheEntries < 2 { + t.Errorf("expected at least 2 cache entries, got %d", stats.CacheEntries) + } + + _ = r.Stop() +} + +// TestResolverRefresh 测试 DNS 缓存刷新 +func TestResolverRefresh(t *testing.T) { + cfg := &config.ResolverConfig{ + Enabled: true, + Addresses: []string{"8.8.8.8:53"}, + Valid: 30 * time.Second, + Timeout: 5 * time.Second, + IPv4: true, + IPv6: false, + } + + r := resolver.New(cfg) + + ctx := context.Background() + + // 先查询一次 + _, _ = r.LookupHostWithCache(ctx, "dns.google") + + // 刷新缓存 + err := r.Refresh("dns.google") + if err != nil { + t.Errorf("refresh failed: %v", err) + } + + _ = r.Stop() +} diff --git a/internal/integration/variable_test.go b/internal/integration/variable_test.go new file mode 100644 index 0000000..542bf6b --- /dev/null +++ b/internal/integration/variable_test.go @@ -0,0 +1,331 @@ +// variable_integration_test.go - 变量系统集成测试 +// +// 测试变量系统与日志、代理、重写的端到端集成 +// +// 作者:xfy +package integration + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/logging" + "rua.plus/lolly/internal/middleware/rewrite" + "rua.plus/lolly/internal/variable" +) + +// TestVariableEndToEndInLogging 测试变量在访问日志中的端到端使用 +func TestVariableEndToEndInLogging(t *testing.T) { + // 创建内存日志输出 + var buf bytes.Buffer + + cfg := &config.LoggingConfig{ + Access: config.AccessLogConfig{ + // 使用完整的 nginx 风格格式 + Format: "$remote_addr - $remote_user [$time_local] \"$request_method $request_uri $scheme\" $status $body_bytes_sent \"$http_user_agent\"", + }, + } + + logger := logging.New(cfg) + + // 模拟请求 + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/users?page=1") + ctx.Request.Header.SetMethod("GET") + ctx.Request.Header.SetHost("example.com") + ctx.Request.Header.Set("User-Agent", "TestAgent/1.0") + + // 记录访问日志 + logger.LogAccess(ctx, 200, 1024, 50*time.Millisecond) + + // 验证输出(这里主要验证不 panic) + _ = buf.String() + t.Log("Logging with variables completed successfully") +} + +// TestVariableInProxyHeaders 测试代理请求头中的变量 +func TestVariableInProxyHeaders(t *testing.T) { + // 创建请求 + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/test") + ctx.Request.Header.SetMethod("POST") + ctx.Request.Header.SetHost("api.example.com") + ctx.Request.Header.Set("X-Custom-Header", "original") + + // 测试变量展开 + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) + + // 模拟代理配置中的 header 设置 + tests := []struct { + template string + contains []string + }{ + {"X-Forwarded-Host: $host", []string{"api.example.com"}}, + {"X-Real-IP: $remote_addr", []string{"0.0.0.0"}}, + {"X-Request-ID: $request_id", []string{"-"}}, // 未设置时为 - + } + + for _, tt := range tests { + result := vc.Expand(tt.template) + for _, expected := range tt.contains { + if !strings.Contains(result, expected) { + t.Errorf("Expand(%q) = %q, expected to contain %q", tt.template, result, expected) + } + } + } +} + +// TestVariableInRewriteRules 测试重写规则中的变量 +func TestVariableInRewriteRules(t *testing.T) { + // 创建带有变量的重写规则 + rules := []config.RewriteRule{ + { + Pattern: "^/api/(.*)$", + Replacement: "/v1/$1", + Flag: "break", + }, + { + Pattern: "^/redirect/(.*)$", + Replacement: "/new/$1", + Flag: "redirect", + }, + } + + mw, err := rewrite.New(rules) + if err != nil { + t.Fatalf("failed to create rewrite middleware: %v", err) + } + + // 测试第一个规则 + t.Run("rewrite_with_capture", func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/users") + ctx.Request.Header.SetMethod("GET") + + var capturedPath string + next := func(c *fasthttp.RequestCtx) { + capturedPath = string(c.Path()) + } + + handler := mw.Process(next) + handler(ctx) + + if capturedPath != "/v1/users" { + t.Errorf("expected path '/v1/users', got %q", capturedPath) + } + }) + + // 测试重定向规则 + t.Run("redirect_with_capture", func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/redirect/old-page") + ctx.Request.Header.SetMethod("GET") + + handler := mw.Process(func(c *fasthttp.RequestCtx) { + t.Error("should not reach next handler for redirect") + }) + handler(ctx) + + // 验证重定向状态码 + if ctx.Response.StatusCode() != 302 { + t.Errorf("expected status 302, got %d", ctx.Response.StatusCode()) + } + }) +} + +// TestVariableCompatibilityWithNginx 测试与 nginx 变量格式的兼容性 +func TestVariableCompatibilityWithNginx(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/test/path?foo=bar&baz=qux") + ctx.Request.Header.SetMethod("POST") + ctx.Request.Header.SetHost("example.com") + ctx.Request.Header.Set("User-Agent", "TestAgent/1.0") + ctx.Request.Header.Set("Referer", "http://referrer.com") + + // 设置响应信息 + variable.SetResponseInfoInContext(ctx, 201, 2048, 100000000) // 100ms + + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) + + // 设置 HTTP 头变量(logging 中会自动设置这些) + vc.Set("http_referer", "http://referrer.com") + vc.Set("http_user_agent", "TestAgent/1.0") + vc.Set("remote_user", "-") + + // 测试 nginx 风格的组合日志格式 + logFormat := "$remote_addr - $remote_user [$time_local] \"$request_method $request_uri $scheme\" $status $body_bytes_sent \"$http_referer\" \"$http_user_agent\"" + result := vc.Expand(logFormat) + + // 验证结果包含期望的部分 + expectedParts := []string{ + "POST", // method + "/test/path", // path + "foo=bar", // query string + "http", // scheme + "201", // status + "2048", // body_bytes_sent + "http://referrer.com", // referer + "TestAgent/1.0", // user agent + } + + for _, part := range expectedParts { + if !strings.Contains(result, part) { + t.Errorf("log output missing %q: got %q", part, result) + } + } + + t.Logf("Generated log: %s", result) +} + +// TestVariablePerformance 测试变量展开性能 +func TestVariablePerformance(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/v1/users/123?active=true") + ctx.Request.Header.SetMethod("GET") + ctx.Request.Header.SetHost("api.example.com") + + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) + + // 常见日志格式模板 + template := "$remote_addr - $remote_user [$time_local] \"$request_method $request_uri $scheme\" $status $body_bytes_sent \"$http_user_agent\"" + + // 执行多次展开 + start := time.Now() + iterations := 10000 + for i := 0; i < iterations; i++ { + _ = vc.Expand(template) + } + elapsed := time.Since(start) + + // 计算平均时间 + avg := elapsed / time.Duration(iterations) + t.Logf("Average expansion time: %v (iterations: %d)", avg, iterations) + + // 验证性能在合理范围内(< 1μs 每次) + if avg > time.Microsecond { + t.Errorf("average time %v exceeds 1μs", avg) + } +} + +// TestVariableEdgeCases 测试变量的边界情况 +func TestVariableEdgeCases(t *testing.T) { + tests := []struct { + name string + setup func(*fasthttp.RequestCtx) + template string + expected string + }{ + { + name: "empty_template", + setup: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/test") + }, + template: "", + expected: "", + }, + { + name: "no_variables", + setup: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/test") + }, + template: "static text without variables", + expected: "static text without variables", + }, + { + name: "undefined_variable", + setup: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/test") + }, + template: "$undefined_var", + expected: "$undefined_var", // 保持原样 + }, + { + name: "mixed_defined_undefined", + setup: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/test") + ctx.Request.Header.SetHost("example.com") + }, + template: "$host-$undefined", + expected: "example.com-$undefined", + }, + { + name: "special_characters_in_value", + setup: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/test%20path") + ctx.Request.Header.SetHost("example.com") + }, + template: "$uri", + expected: "/test path", // fasthttp Path() 返回解码后的路径 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + tt.setup(ctx) + + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) + + result := vc.Expand(tt.template) + if result != tt.expected { + t.Errorf("Expand(%q) = %q, want %q", tt.template, result, tt.expected) + } + }) + } +} + +// TestVariableAllBuiltins 测试所有内置变量 +func TestVariableAllBuiltins(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/test/path?foo=bar") + ctx.Request.Header.SetMethod("GET") + ctx.Request.Header.SetHost("example.com") + + // 设置响应信息 + variable.SetResponseInfoInContext(ctx, 200, 1024, 50000000) // 50ms + + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) + + // 测试所有内置变量 + builtinVars := []string{ + "host", + "remote_addr", + "remote_port", + "request_uri", + "uri", + "args", + "request_method", + "scheme", + "server_name", + "server_port", + "status", + "body_bytes_sent", + "request_time", + "time_local", + "time_iso8601", + "request_id", + } + + for _, varName := range builtinVars { + t.Run(varName, func(t *testing.T) { + value, ok := vc.Get(varName) + if !ok { + t.Errorf("builtin variable %q not found", varName) + return + } + if value == "" && varName != "args" { + t.Logf("Warning: %q is empty", varName) + } + t.Logf("%s = %q", varName, value) + }) + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index cbdec54..4f8bfb8 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -21,13 +21,13 @@ import ( "fmt" "io" "os" - "strconv" "strings" "time" "github.com/rs/zerolog" "github.com/valyala/fasthttp" "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/variable" ) // Logger 日志管理器,分离访问日志和错误日志。 @@ -140,7 +140,7 @@ func (l *Logger) LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, dur // formatAccessLog 根据模板格式化访问日志。 // -// 支持以下变量替换: +// 使用变量系统展开模板字符串,支持以下变量: // - $remote_addr: 客户端地址 // - $remote_user: 认证用户 // - $request: 请求方法和路径 @@ -149,7 +149,8 @@ func (l *Logger) LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, dur // - $request_time: 请求处理时间 // - $http_referer: Referer 头 // - $http_user_agent: User-Agent 头 -// - $time: 当前时间 +// - $time_local, $time_iso8601: 时间 +// - $host, $uri, $args: 请求信息 // // 参数: // - ctx: FastHTTP 请求上下文 @@ -168,23 +169,24 @@ func (l *Logger) formatAccessLog(ctx *fasthttp.RequestCtx, status int, size int6 } } - replacements := map[string]string{ - "$remote_addr": ctx.RemoteAddr().String(), - "$remote_user": remoteUser, - "$request": string(ctx.Method()) + " " + string(ctx.Path()) + " " + string(ctx.Request.Header.Protocol()), - "$status": strconv.Itoa(status), - "$body_bytes_sent": strconv.FormatInt(size, 10), - "$request_time": fmt.Sprintf("%.6f", duration.Seconds()), - "$http_referer": string(ctx.Request.Header.Peek("Referer")), - "$http_user_agent": string(ctx.Request.Header.Peek("User-Agent")), - "$time": time.Now().Format(time.RFC3339), - } + // 创建变量上下文 + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) - result := l.accessFormat - for varName, value := range replacements { - result = strings.ReplaceAll(result, varName, value) - } - return result + // 设置响应信息(同时设置到 ctx 供 builtin getter 使用) + vc.SetResponseInfo(status, size, duration.Nanoseconds()) + variable.SetResponseInfoInContext(ctx, status, size, duration.Nanoseconds()) + + // 设置自定义变量(用于兼容旧的变量名) + vc.Set("remote_user", remoteUser) + vc.Set("request", string(ctx.Method())+" "+string(ctx.Path())+" "+string(ctx.Request.Header.Protocol())) + vc.Set("http_referer", string(ctx.Request.Header.Peek("Referer"))) + vc.Set("http_user_agent", string(ctx.Request.Header.Peek("User-Agent"))) + // 添加 $time 别名(兼容旧格式) + vc.Set("time", time.Now().Format(time.RFC3339)) + + // 展开模板 + return vc.Expand(l.accessFormat) } // Debug 返回 Debug 级别日志记录器。 diff --git a/internal/middleware/rewrite/rewrite.go b/internal/middleware/rewrite/rewrite.go index d221691..ed8846d 100644 --- a/internal/middleware/rewrite/rewrite.go +++ b/internal/middleware/rewrite/rewrite.go @@ -8,6 +8,7 @@ import ( "github.com/valyala/fasthttp" "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/variable" ) // MaxRewriteIterations URL重写最大迭代次数,防止无限循环 @@ -134,6 +135,11 @@ func (m *RewriteMiddleware) Process(next fasthttp.RequestHandler) fasthttp.Reque // 执行正则替换 newPath := rule.pattern.ReplaceAllString(path, rule.replacement) + // 对替换结果进行变量展开 + vc := variable.NewVariableContext(ctx) + newPath = vc.Expand(newPath) + variable.ReleaseVariableContext(vc) + switch rule.flag { case FlagRedirect: ctx.Redirect(newPath, fasthttp.StatusFound) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 9452253..b386fff 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -37,6 +37,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "github.com/valyala/fasthttp" @@ -45,6 +46,8 @@ import ( "rua.plus/lolly/internal/loadbalance" "rua.plus/lolly/internal/logging" "rua.plus/lolly/internal/netutil" + "rua.plus/lolly/internal/resolver" + "rua.plus/lolly/internal/variable" ) // Proxy 表示反向代理实例,负责将 HTTP 请求转发到后端目标。 @@ -73,6 +76,15 @@ type Proxy struct { // healthChecker 健康检查器,用于被动健康检查 healthChecker *HealthChecker + // resolver DNS 解析器,用于动态解析域名 + resolver resolver.Resolver + + // stopCh 用于停止后台协程 + stopCh chan struct{} + + // started 标记代理是否已启动 + started atomic.Bool + // mu 保护并发访问的读写锁 mu sync.RWMutex } @@ -108,6 +120,7 @@ func NewProxy(cfg *config.ProxyConfig, targets []*loadbalance.Target, transportC clients: make(map[string]*fasthttp.HostClient), balancer: balancer, config: cfg, + stopCh: make(chan struct{}), } // 为每个后端目标初始化 HostClient @@ -512,10 +525,13 @@ func (p *Proxy) modifyRequestHeaders(ctx *fasthttp.RequestCtx, target *loadbalan } headers.Set("X-Forwarded-Proto", proto) - // 从配置设置自定义请求头 + // 从配置设置自定义请求头(支持变量展开) if p.config.Headers.SetRequest != nil { + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) for key, value := range p.config.Headers.SetRequest { - headers.Set(key, value) + expanded := vc.Expand(value) + headers.Set(key, expanded) } } @@ -529,10 +545,13 @@ func (p *Proxy) modifyRequestHeaders(ctx *fasthttp.RequestCtx, target *loadbalan // modifyResponseHeaders 在发送给客户端之前修改响应头。 func (p *Proxy) modifyResponseHeaders(ctx *fasthttp.RequestCtx) { - // 从配置设置自定义响应头 + // 从配置设置自定义响应头(支持变量展开) if p.config.Headers.SetResponse != nil { + vc := variable.NewVariableContext(ctx) + defer variable.ReleaseVariableContext(vc) for key, value := range p.config.Headers.SetResponse { - ctx.Response.Header.Set(key, value) + expanded := vc.Expand(value) + ctx.Response.Header.Set(key, expanded) } } } diff --git a/internal/proxy/proxy_dns.go b/internal/proxy/proxy_dns.go new file mode 100644 index 0000000..05e7e76 --- /dev/null +++ b/internal/proxy/proxy_dns.go @@ -0,0 +1,182 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "net/url" + "time" + + "rua.plus/lolly/internal/loadbalance" + "rua.plus/lolly/internal/logging" + "rua.plus/lolly/internal/resolver" +) + +// SetResolver 设置 DNS 解析器。 +func (p *Proxy) SetResolver(r resolver.Resolver) { + p.mu.Lock() + defer p.mu.Unlock() + p.resolver = r +} + +// Start 启动代理,包括 DNS 刷新循环。 +func (p *Proxy) Start() error { + if p.started.Load() { + return nil + } + + p.started.Store(true) + + // 启动 DNS 刷新循环(如果配置了 resolver) + if p.resolver != nil { + if err := p.resolver.Start(); err != nil { + return fmt.Errorf("failed to start resolver: %w", err) + } + go p.startDNSRefreshLoop() + } + + return nil +} + +// Stop 停止代理,包括关闭 DNS 刷新循环。 +func (p *Proxy) Stop() error { + if !p.started.Load() { + return nil + } + + p.started.Store(false) + + // 关闭 stopCh 通知所有后台协程退出 + close(p.stopCh) + + // 停止 resolver + if p.resolver != nil { + if err := p.resolver.Stop(); err != nil { + return fmt.Errorf("failed to stop resolver: %w", err) + } + } + + return nil +} + +// startDNSRefreshLoop 启动 DNS 刷新后台循环。 +func (p *Proxy) startDNSRefreshLoop() { + if p.resolver == nil { + return + } + + ttl := p.getResolverTTL() + if ttl == 0 { + ttl = 30 * time.Second + } + + // 刷新间隔为 TTL / 2 + interval := ttl / 2 + if interval < time.Second { + interval = time.Second + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + p.refreshDNS() + case <-p.stopCh: + return + } + } +} + +// refreshDNS 刷新所有需要解析的目标。 +func (p *Proxy) refreshDNS() { + if p.resolver == nil { + return + } + + ttl := p.getResolverTTL() + + p.mu.RLock() + targets := p.targets + p.mu.RUnlock() + + for _, target := range targets { + if !target.NeedsResolve(ttl) { + continue + } + + hostname := target.Hostname() + if hostname == "" { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ips, err := p.resolver.LookupHostWithCache(ctx, hostname) + cancel() + + if err != nil { + logging.Debug().Msgf("DNS refresh failed for %s: %v", hostname, err) + continue + } + + if len(ips) > 0 { + target.SetResolvedIPs(ips) + p.updateHostClientAddr(target, ips[0]) + } + } +} + +// updateHostClientAddr 更新 HostClient 的 Addr。 +func (p *Proxy) updateHostClientAddr(target *loadbalance.Target, ip string) { + p.mu.Lock() + defer p.mu.Unlock() + + // 从 URL 解析出端口 + u, err := url.Parse(target.URL) + if err != nil { + return + } + + _, port, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + newAddr := net.JoinHostPort(ip, port) + + // 更新 HostClient 的 Addr + // 注意:新连接将使用新 IP,旧连接继续使用旧 IP 直到超时 + if client, ok := p.clients[target.URL]; ok { + client.Addr = newAddr + logging.Debug().Msgf("Updated HostClient addr for %s to %s", target.URL, newAddr) + } +} + +// getResolverTTL 获取 resolver 的 TTL。 +func (p *Proxy) getResolverTTL() time.Duration { + if p.resolver == nil { + return 0 + } + + // 从 stats 中推断 TTL(如果实现了相应接口) + // 这里返回默认值 + return 30 * time.Second +} + +// GetResolverStats 返回 DNS 解析器的统计信息。 +func (p *Proxy) GetResolverStats() resolver.ResolverStats { + p.mu.RLock() + r := p.resolver + p.mu.RUnlock() + + if r == nil { + return resolver.ResolverStats{} + } + return r.Stats() +} diff --git a/internal/server/server.go b/internal/server/server.go index 15acc16..56997f0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -42,6 +42,7 @@ import ( "rua.plus/lolly/internal/middleware/rewrite" "rua.plus/lolly/internal/middleware/security" "rua.plus/lolly/internal/proxy" + "rua.plus/lolly/internal/resolver" "rua.plus/lolly/internal/ssl" ) @@ -107,6 +108,9 @@ type Server struct { // listeners 保存的监听器列表,用于热升级 listeners []net.Listener + + // resolver DNS 解析器(可选) + resolver resolver.Resolver } // New 创建 HTTP 服务器实例。 @@ -600,6 +604,14 @@ func (s *Server) registerProxyRoutes(router *handler.Router, serverCfg *config.S continue } + // 设置 DNS 解析器(如果已配置) + if s.resolver != nil { + p.SetResolver(s.resolver) + if err := p.Start(); err != nil { + logging.Error().Err(err).Msg("启动代理失败") + } + } + // 启动健康检查 if proxyCfg.HealthCheck.Interval > 0 { hc := proxy.NewHealthChecker(targets, &proxyCfg.HealthCheck) @@ -771,3 +783,13 @@ func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.Serv router.HEAD(routePath+"{filepath:*}", staticHandler.Handle) } } + +// SetResolver 设置 DNS 解析器。 +func (s *Server) SetResolver(r resolver.Resolver) { + s.resolver = r +} + +// GetResolver 返回 DNS 解析器。 +func (s *Server) GetResolver() resolver.Resolver { + return s.resolver +}