feat(proxy,server,logging): 集成变量系统和 DNS 解析器

变量系统集成:
- 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 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-08 11:29:48 +08:00
parent 5a6f4d351f
commit 9495a548e6
9 changed files with 1057 additions and 23 deletions

230
docs/config-reference.md Normal file
View File

@ -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"
```

View File

@ -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)

View File

@ -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()
}

View File

@ -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)
})
}
}

View File

@ -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 级别日志记录器。

View File

@ -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)

View File

@ -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)
}
}
}

182
internal/proxy/proxy_dns.go Normal file
View File

@ -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()
}

View File

@ -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
}