feat: add trusted proxy identity and overload stats

This commit is contained in:
2026-09-22 18:19:39 +08:00
parent 2259606f72
commit 95dc4568f6
17 changed files with 346 additions and 42 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。 - 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。
- native 新增 `Server.Stats()`,提供 accepted connections、active connections 和 active requests 生命周期指标。 - native 新增 `Server.Stats()`,提供 accepted connections、active connections 和 active requests 生命周期指标。
- native 请求体改为流式读取,Content-Length 与 chunked body 不再在进入 Handler 前整体复制到内存;保留大小限制、读超时、trailer 校验和 Keep-Alive 排空。 - native 请求体改为流式读取,Content-Length 与 chunked body 不再在进入 Handler 前整体复制到内存;保留大小限制、读超时、trailer 校验和 Keep-Alive 排空。

View File

@@ -124,7 +124,7 @@ if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。 `transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
自研网络层还提供 `server.Stats()`,可读取 accepted、active connections 和 active requests,与上面的业务请求 metrics 组合后能区分连接堆积和 Handler 慢请求。 自研网络层还提供 `server.Stats()`,可读取 accepted、rejected、active connections 和 active requests,与上面的业务请求 metrics 组合后能区分连接堆积、过载拒绝和 Handler 慢请求。`rejectedConnections` 表示连接闸门拒绝数,`rejectedRequests` 表示并发请求闸门返回 503 的次数。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库: 业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
@@ -164,7 +164,10 @@ application.OnShutdown(logger.Close)
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、 常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、 `GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由 `GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR`、`GO_OMNI_SHUTDOWN_TIMEOUT` 和
`GO_OMNI_TRUSTED_PROXIES`。可信代理变量使用逗号分隔的 IP/CIDR,例如
`GO_OMNI_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8`;只有来自这些地址的连接才会解析
`X-Forwarded-For`,默认只记录 socket 对端地址。数据库连接仍由
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。 `database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。 `App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。

View File

@@ -48,6 +48,7 @@
- 已补充慢请求头、慢 Content-Length body、慢 chunked body、慢 TLS 握手、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux/arm64 容器已通过全量 race、vet、build,以及 3 秒 TLS 并发/空闲连接矩阵。原始报告见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json)。 - 已补充慢请求头、慢 Content-Length body、慢 chunked body、慢 TLS 握手、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux/arm64 容器已通过全量 race、vet、build,以及 3 秒 TLS 并发/空闲连接矩阵。原始报告见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json)。
- 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。 - 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。
- 已验证 `MaxConcurrentRequests` 满载返回 503,活动请求结束后槽位释放,后续请求可以恢复处理。 - 已验证 `MaxConcurrentRequests` 满载返回 503,活动请求结束后槽位释放,后续请求可以恢复处理。
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。 - 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。 - 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。 - Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。

View File

@@ -21,12 +21,13 @@ import (
// App is the top-level GoOmni application. Its public behavior depends on // App is the top-level GoOmni application. Its public behavior depends on
// httpx.Router, leaving the network implementation replaceable. // httpx.Router, leaving the network implementation replaceable.
type App struct { type App struct {
router httpx.Router router httpx.Router
config config.Config config config.Config
middlewares []httpx.Middleware middlewares []httpx.Middleware
closers []func() error closers []func() error
closeOnce sync.Once closeOnce sync.Once
closeErr error closeErr error
clientIPResolver *httpx.ClientIPResolver
} }
// Runner is the small lifecycle boundary used by an HTTP server // Runner is the small lifecycle boundary used by an HTTP server
@@ -63,12 +64,17 @@ func configure(options ...config.Option) config.Config {
} }
func newApp(router httpx.Router, settings config.Config) *App { func newApp(router httpx.Router, settings config.Config) *App {
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
if err != nil {
panic("go-omni: " + err.Error())
}
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)} middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
if settings.EnableLogger { if settings.EnableLogger {
middlewares = append(middlewares, middleware.AccessLog()) middlewares = append(middlewares, middleware.AccessLog())
} }
middlewares = append(middlewares, middleware.Recovery()) middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares} return &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver}
} }
// Router returns the transport contract used by this application. // Router returns the transport contract used by this application.
@@ -84,7 +90,11 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
} }
// Config returns the effective application configuration. // Config returns the effective application configuration.
func (a *App) Config() config.Config { return a.config } func (a *App) Config() config.Config {
settings := a.config
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
return settings
}
// Use adds middleware to routes registered after this call. // Use adds middleware to routes registered after this call.
func (a *App) Use(middleware ...httpx.Middleware) *App { func (a *App) Use(middleware ...httpx.Middleware) *App {

View File

@@ -21,6 +21,9 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
} }
} }
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if len(a.config.TrustedProxies) > 0 {
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
}
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context())) requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
if err := wrapped(requestContext); err != nil { if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err) a.writeError(requestContext, err)

View File

@@ -128,3 +128,34 @@ func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
t.Fatalf("expected configured address %q, got %q", settings.Address, got) t.Fatalf("expected configured address %q, got %q", settings.Address, got)
} }
} }
func TestAppConfigCopiesTrustedProxies(t *testing.T) {
proxies := []string{"127.0.0.1"}
app := New(WithTrustedProxies(proxies...))
proxies[0] = "10.0.0.0/8"
settings := app.Config()
if len(settings.TrustedProxies) != 1 || settings.TrustedProxies[0] != "127.0.0.1" {
t.Fatalf("trusted proxy config was not copied: %+v", settings.TrustedProxies)
}
settings.TrustedProxies[0] = "192.0.2.0/24"
if app.Config().TrustedProxies[0] != "127.0.0.1" {
t.Fatal("App.Config should return a copy")
}
}
func TestAppTrustedProxyClientIPReachesHandlers(t *testing.T) {
app := New(WithTrustedProxies("127.0.0.1"))
app.GET("/client-ip", func(ctx *Context) error { return ctx.OK(ctx.ClientIP()) })
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
request.RemoteAddr = "127.0.0.1:443"
request.Header.Set("X-Forwarded-For", "198.51.100.7")
recorder := httptest.NewRecorder()
app.Engine().ServeHTTP(recorder, request)
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Data != "198.51.100.7" {
t.Fatalf("expected trusted proxy client IP, got %#v", response.Data)
}
}

View File

@@ -4,6 +4,7 @@ import (
"strings" "strings"
"time" "time"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
) )
@@ -14,6 +15,7 @@ type Config struct {
Address string Address string
RequestIDHeader string RequestIDHeader string
EnableLogger bool EnableLogger bool
TrustedProxies []string
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
Log logx.Config Log logx.Config
} }
@@ -38,6 +40,9 @@ func (c Config) Validate() error {
if c.ShutdownTimeout <= 0 { if c.ShutdownTimeout <= 0 {
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"} return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
} }
if _, err := httpx.NewClientIPResolver(c.TrustedProxies...); err != nil {
return &ConfigError{Field: "trusted_proxies", Message: err.Error()}
}
return nil return nil
} }
@@ -66,10 +71,22 @@ func WithLogger(enabled bool) Option {
return func(cfg *Config) { cfg.EnableLogger = enabled } return func(cfg *Config) { cfg.EnableLogger = enabled }
} }
// WithTrustedProxies allows only these IPs/CIDRs to supply X-Forwarded-For.
// An empty list disables forwarding-header trust. Invalid entries are rejected
// by Config.Validate and panic when passed directly to App.New.
func WithTrustedProxies(proxies ...string) Option {
proxies = append([]string(nil), proxies...)
return func(cfg *Config) { cfg.TrustedProxies = append([]string(nil), proxies...) }
}
// WithConfig applies a complete configuration loaded from a file or // WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields. // environment. Later options can still override individual fields.
func WithConfig(settings Config) Option { func WithConfig(settings Config) Option {
return func(cfg *Config) { *cfg = settings } settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
return func(cfg *Config) {
*cfg = settings
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
}
} }
// WithLogConfig configures the logger used by the application runtime. // WithLogConfig configures the logger used by the application runtime.

View File

@@ -9,6 +9,7 @@ import (
func TestLoadFromEnvUsesDefaults(t *testing.T) { func TestLoadFromEnvUsesDefaults(t *testing.T) {
for _, key := range []string{ for _, key := range []string{
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER", "GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
"GO_OMNI_TRUSTED_PROXIES",
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT", "GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT", "GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT", "GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
@@ -31,6 +32,7 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
func TestLoadFromEnvOverridesSettings(t *testing.T) { func TestLoadFromEnvOverridesSettings(t *testing.T) {
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090") t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID") t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true") t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
t.Setenv("GO_OMNI_LOG_LEVEL", "debug") t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
t.Setenv("GO_OMNI_LOG_FORMAT", "text") t.Setenv("GO_OMNI_LOG_FORMAT", "text")
@@ -44,7 +46,7 @@ func TestLoadFromEnvOverridesSettings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger { if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
t.Fatalf("unexpected application settings: %+v", cfg) t.Fatalf("unexpected application settings: %+v", cfg)
} }
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource { if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
@@ -65,6 +67,11 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
} }
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s") t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("expected invalid trusted proxy error, got %v", err)
}
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
cfg := Default() cfg := Default()
cfg.Address = "" cfg.Address = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") { if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
@@ -80,11 +87,16 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
func TestWithConfigAndWithLogConfig(t *testing.T) { func TestWithConfigAndWithLogConfig(t *testing.T) {
settings := Default() settings := Default()
settings.Log.Service = "orders" settings.Log.Service = "orders"
settings.TrustedProxies = []string{"127.0.0.1"}
cfg := Default() cfg := Default()
WithConfig(settings)(&cfg) WithConfig(settings)(&cfg)
if cfg.Log.Service != "orders" { if cfg.Log.Service != "orders" || len(cfg.TrustedProxies) != 1 {
t.Fatalf("complete config was not applied: %+v", cfg) t.Fatalf("complete config was not applied: %+v", cfg)
} }
settings.TrustedProxies[0] = "10.0.0.0/8"
if cfg.TrustedProxies[0] != "127.0.0.1" {
t.Fatal("WithConfig should copy trusted proxy settings")
}
WithLogConfig(Default().Log)(&cfg) WithLogConfig(Default().Log)(&cfg)
if cfg.Log.Service != "" { if cfg.Log.Service != "" {

View File

@@ -20,6 +20,12 @@ func LoadFromEnv(prefix string) (Config, error) {
cfg := Default() cfg := Default()
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address) cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader) cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
cfg.TrustedProxies = strings.Split(value, ",")
for i := range cfg.TrustedProxies {
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
}
}
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level) cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format) cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output) cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)

105
httpx/clientIP.go Normal file
View File

@@ -0,0 +1,105 @@
package httpx
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
)
// ClientIPResolver trusts X-Forwarded-For only from explicitly configured
// proxy addresses. It is immutable and safe to share between requests.
// The zero value (and a nil resolver) trusts no proxy.
type ClientIPResolver struct {
trusted []netip.Prefix
}
// NewClientIPResolver accepts IP addresses or CIDR networks, never hostnames.
func NewClientIPResolver(proxies ...string) (*ClientIPResolver, error) {
r := &ClientIPResolver{}
for _, value := range proxies {
value = strings.TrimSpace(value)
if address, err := netip.ParseAddr(value); err == nil && address.Zone() == "" {
address = address.Unmap()
r.trusted = append(r.trusted, netip.PrefixFrom(address, address.BitLen()))
continue
}
prefix, err := netip.ParsePrefix(value)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy %q: expected an IP address or CIDR", value)
}
if prefix.Addr().Is4In6() {
if prefix.Bits() < 96 {
return nil, fmt.Errorf("invalid trusted proxy %q: mapped IPv4 prefix must be at least /96", value)
}
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
r.trusted = append(r.trusted, prefix.Masked())
}
return r, nil
}
func (r *ClientIPResolver) trusts(address netip.Addr) bool {
if r != nil {
for _, prefix := range r.trusted {
if prefix.Contains(address) {
return true
}
}
}
return false
}
// ClientIP walks X-Forwarded-For from right to left, stopping at the first
// untrusted address. Missing or malformed forwarding data falls back to the
// socket peer. X-Real-IP, Forwarded and provider-specific headers are ignored.
func (r *ClientIPResolver) ClientIP(request *http.Request) string {
if request == nil {
return ""
}
host := request.RemoteAddr
if value, _, err := net.SplitHostPort(host); err == nil {
host = value
}
peer, err := netip.ParseAddr(host)
if err != nil {
return ""
}
peer = peer.WithZone("").Unmap()
if !r.trusts(peer) {
return peer.String()
}
values := request.Header.Values("X-Forwarded-For")
var chain []netip.Addr
for _, value := range values {
for _, part := range strings.Split(value, ",") {
address, err := netip.ParseAddr(strings.TrimSpace(part))
if err != nil || address.Zone() != "" {
return peer.String()
}
chain = append(chain, address.Unmap())
}
}
current := peer
for i := len(chain) - 1; i >= 0 && r.trusts(current); i-- {
current = chain[i]
}
return current.String()
}
type clientIPResolverKey struct{}
// WithClientIPResolver configures ClientIP for Context values derived from ctx.
// Adapters should set this before running application middleware.
func WithClientIPResolver(ctx context.Context, resolver *ClientIPResolver) context.Context {
return context.WithValue(ctx, clientIPResolverKey{}, resolver)
}
// ClientIP returns the verified client address without a port. By default it
// returns the socket peer; App's trusted proxy configuration enables XFF.
func (c *Context) ClientIP() string {
resolver, _ := c.Context().Value(clientIPResolverKey{}).(*ClientIPResolver)
return resolver.ClientIP(c.request)
}

76
httpx/clientIP_test.go Normal file
View File

@@ -0,0 +1,76 @@
package httpx_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
func TestClientIPResolverUsesSocketPeerByDefault(t *testing.T) {
resolver, err := httpx.NewClientIPResolver()
if err != nil {
t.Fatal(err)
}
request := newClientIPRequest("192.0.2.10:443", "198.51.100.7")
if got := resolver.ClientIP(request); got != "192.0.2.10" {
t.Fatalf("untrusted forwarding header changed client IP: %q", got)
}
}
func TestClientIPResolverWalksTrustedForwardingChain(t *testing.T) {
resolver, err := httpx.NewClientIPResolver("10.0.0.0/8")
if err != nil {
t.Fatal(err)
}
request := newClientIPRequest("10.0.0.5:443", "198.51.100.7, 10.0.0.4")
if got := resolver.ClientIP(request); got != "198.51.100.7" {
t.Fatalf("expected original client IP, got %q", got)
}
request = newClientIPRequest("10.0.0.5:443", "198.51.100.7, 203.0.113.9, 10.0.0.4")
if got := resolver.ClientIP(request); got != "203.0.113.9" {
t.Fatalf("expected first untrusted hop, got %q", got)
}
}
func TestClientIPResolverRejectsMalformedForwardingData(t *testing.T) {
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
if err != nil {
t.Fatal(err)
}
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7, not-an-ip")
if got := resolver.ClientIP(request); got != "127.0.0.1" {
t.Fatalf("malformed forwarding header should fall back to peer, got %q", got)
}
}
func TestNewClientIPResolverRejectsHostnames(t *testing.T) {
if _, err := httpx.NewClientIPResolver("proxy.example.com"); err == nil || !strings.Contains(err.Error(), "invalid trusted proxy") {
t.Fatalf("expected hostname validation error, got %v", err)
}
}
func TestContextClientIPUsesResolverFromRequestContext(t *testing.T) {
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
if err != nil {
t.Fatal(err)
}
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7")
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), resolver))
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
if got := ctx.ClientIP(); got != "198.51.100.7" {
t.Fatalf("expected context client IP, got %q", got)
}
}
func newClientIPRequest(remote, forwarded string) *http.Request {
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
request.RemoteAddr = remote
if forwarded != "" {
request.Header.Set("X-Forwarded-For", forwarded)
}
return request
}

View File

@@ -2,7 +2,6 @@ package middleware
import ( import (
"fmt" "fmt"
"net/http"
"time" "time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
@@ -64,19 +63,9 @@ func AccessLog() httpx.Middleware {
"route", httpx.RoutePattern(ctx.Context()), "route", httpx.RoutePattern(ctx.Context()),
"status", status, "status", status,
"durationMs", time.Since(started).Seconds()*1000, "durationMs", time.Since(started).Seconds()*1000,
"clientIp", clientIP(ctx.Request()), "clientIp", ctx.ClientIP(),
) )
return err return err
} }
} }
} }
func clientIP(request *http.Request) string {
if request == nil {
return ""
}
if value := request.Header.Get("X-Forwarded-For"); value != "" {
return value
}
return request.RemoteAddr
}

View File

@@ -55,3 +55,34 @@ func TestAccessLogReportsCommittedStatus(t *testing.T) {
}) })
} }
} }
func TestAccessLogUsesVerifiedClientIP(t *testing.T) {
loggerOutput := bytes.Buffer{}
logger, err := logx.New(logx.Config{Format: "json", Writer: &loggerOutput})
if err != nil {
t.Fatal(err)
}
logx.SetDefault(logger)
defer logx.SetDefault(nil)
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("GET", "/health", nil)
request.RemoteAddr = "127.0.0.1:443"
request.Header.Set("X-Forwarded-For", "198.51.100.7")
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), resolver))
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
if err := middleware.AccessLog()(func(c *httpx.Context) error { return c.OK("ok") })(ctx); err != nil {
t.Fatal(err)
}
var entry struct {
ClientIP string `json:"clientIp"`
}
if err := json.Unmarshal(loggerOutput.Bytes(), &entry); err != nil {
t.Fatal(err)
}
if entry.ClientIP != "198.51.100.7" {
t.Fatalf("expected verified client IP, got %q", entry.ClientIP)
}
}

View File

@@ -41,6 +41,8 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) } func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithTrustedProxies(proxies ...string) Option { return config.WithTrustedProxies(proxies...) }
func WithConfig(settings Config) Option { return config.WithConfig(settings) } func WithConfig(settings Config) Option { return config.WithConfig(settings) }
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) } func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }

View File

@@ -16,7 +16,7 @@ registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
`transport/native` 是第一版自研 HTTP/1.1 服务端:它自己管理 TCP listener、连接、Keep-Alive、请求解析、响应写回和优雅关闭,再把请求交给现有 `http.Handler`。请求体支持 `Content-Length` 和 chunked,并统一受 `MaxBodyBytes` 限制;其他传输编码会被拒绝。它仍使用标准 `http.Request` / `http.ResponseWriter` 作为业务桥接,因此不会影响现有 Handler / Service。 `transport/native` 是第一版自研 HTTP/1.1 服务端:它自己管理 TCP listener、连接、Keep-Alive、请求解析、响应写回和优雅关闭,再把请求交给现有 `http.Handler`。请求体支持 `Content-Length` 和 chunked,并统一受 `MaxBodyBytes` 限制;其他传输编码会被拒绝。它仍使用标准 `http.Request` / `http.ResponseWriter` 作为业务桥接,因此不会影响现有 Handler / Service。
native 提供 `server.Stats()` 读取已接受连接数、当前连接数和当前请求数,适合和 `metrics.Metrics` 的业务请求指标一起采集;读取不会阻塞连接生命周期。 native 提供 `server.Stats()` 读取已接受连接数、连接拒绝数、当前连接数和当前请求数,适合和 `metrics.Metrics` 的业务请求指标一起采集;读取不会阻塞连接生命周期。`RejectedConnections` 统计连接闸门拒绝,`RejectedRequests` 统计并发请求闸门返回 503。
业务 Handler 需要流式返回时可以断言 `http.Flusher` 并调用 `Flush()`;HTTP/1.1 会自动使用 chunked 响应,普通未 Flush 的响应仍自动生成 `Content-Length`。HTTP/1.0 没有 chunked framing,会退回到写完后关闭连接。 业务 Handler 需要流式返回时可以断言 `http.Flusher` 并调用 `Flush()`;HTTP/1.1 会自动使用 chunked 响应,普通未 Flush 的响应仍自动生成 `Content-Length`。HTTP/1.0 没有 chunked framing,会退回到写完后关闭连接。
@@ -24,7 +24,9 @@ Handler 运行超过约 5 ms 时,native 启动一次阻塞读取监测;读
如果客户端提前发送了下一条请求,监测器会保留管线数据并退出,避免预读不断占用内存。这种情况下,断开取消会延后到写失败或 Handler 返回;业务仍应为下游调用设置自己的超时。 如果客户端提前发送了下一条请求,监测器会保留管线数据并退出,避免预读不断占用内存。这种情况下,断开取消会延后到写失败或 Handler 返回;业务仍应为下游调用设置自己的超时。
服务端不是把连接交给一个全局串行 worker,而是每条连接独立处理请求;Keep-Alive 负责复用客户端连接,`Server` 内部的 `sync.Pool` 负责复用小型 Reader、响应对象和连接状态。Reader 默认只有 4 KiB,需要处理大请求头时按需增长。需要保护内存上限时,可以设置 `MaxConnections` 和 `MaxConcurrentRequests`;超出连接上限的连接会被关闭,超出请求并发上限的请求返回 503,不会无限排队。 服务端不是把连接交给一个全局串行 worker,而是每条连接独立处理请求;Keep-Alive 负责复用客户端连接,`Server` 内部的 `sync.Pool` 负责复用小型 Reader、响应对象和连接状态。Reader 默认只有 4 KiB,需要处理大请求头时按需增长。需要保护内存上限时,可以设置 `MaxConnections` 和 `MaxConcurrentRequests`;超出连接上限的连接会被关闭,超出请求并发上限的请求返回 503,不会无限排队,拒绝数可从 `Stats()` 采集。
访问日志默认只记录 TCP socket 对端地址。部署在 Nginx、Cloudflare 等反向代理后面时,通过 `omni.WithTrustedProxies("127.0.0.1/32")` 或 `GO_OMNI_TRUSTED_PROXIES` 配置可信代理网段,框架才会从 `X-Forwarded-For` 右向左校验并取得真实客户端地址;未配置时转发头不会被信任。
这个设计借鉴了 Nginx 的资源边界,但没有照搬它的进程模型:Nginx 用少量 worker 进程和事件机制管理大量连接,并用 `worker_connections` 限制每个 worker 的同时连接数;Go runtime 自己已经用 epoll / kqueue 等网络轮询器唤醒阻塞的 goroutine,因此 native 采用“accept loop + 每连接轻量 goroutine + 请求并发闸门”的等价实现。这样业务 Handler 可以继续执行同步代码,慢请求不会阻塞其他连接;只有在真实压测证明 goroutine 调度或协议状态机成为瓶颈时,才需要继续下沉为手写事件循环。 这个设计借鉴了 Nginx 的资源边界,但没有照搬它的进程模型:Nginx 用少量 worker 进程和事件机制管理大量连接,并用 `worker_connections` 限制每个 worker 的同时连接数;Go runtime 自己已经用 epoll / kqueue 等网络轮询器唤醒阻塞的 goroutine,因此 native 采用“accept loop + 每连接轻量 goroutine + 请求并发闸门”的等价实现。这样业务 Handler 可以继续执行同步代码,慢请求不会阻塞其他连接;只有在真实压测证明 goroutine 调度或协议状态机成为瓶颈时,才需要继续下沉为手写事件循环。

View File

@@ -53,27 +53,32 @@ type Server struct {
// when the listener and TLS configuration are assembled by the caller. // when the listener and TLS configuration are assembled by the caller.
TLSConfig *tls.Config TLSConfig *tls.Config
mu sync.Mutex mu sync.Mutex
handler http.Handler handler http.Handler
listener net.Listener listener net.Listener
conns map[*connection]struct{} conns map[*connection]struct{}
closing bool closing bool
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
connSlots chan struct{} connSlots chan struct{}
requestSlots chan struct{} requestSlots chan struct{}
accepted atomic.Uint64 accepted atomic.Uint64
activeConns atomic.Int64 rejectedConns atomic.Uint64
activeRequests atomic.Int64 rejectedRequests atomic.Uint64
readerPool sync.Pool activeConns atomic.Int64
writerPool sync.Pool activeRequests atomic.Int64
connectionPool sync.Pool readerPool sync.Pool
writerPool sync.Pool
connectionPool sync.Pool
} }
// Stats is a point-in-time view of the native server lifecycle. Counters are // Stats is a point-in-time view of the native server lifecycle. Counters are
// monotonic unless their name starts with Active. // monotonic unless their name starts with Active.
type Stats struct { type Stats struct {
AcceptedConnections uint64 `json:"acceptedConnections"` AcceptedConnections uint64 `json:"acceptedConnections"`
// Rejections count admission decisions, even if writing a 503 fails.
RejectedConnections uint64 `json:"rejectedConnections"`
RejectedRequests uint64 `json:"rejectedRequests"`
ActiveConnections int64 `json:"activeConnections"` ActiveConnections int64 `json:"activeConnections"`
ActiveRequests int64 `json:"activeRequests"` ActiveRequests int64 `json:"activeRequests"`
} }
@@ -86,6 +91,8 @@ func (s *Server) Stats() Stats {
} }
return Stats{ return Stats{
AcceptedConnections: s.accepted.Load(), AcceptedConnections: s.accepted.Load(),
RejectedConnections: s.rejectedConns.Load(),
RejectedRequests: s.rejectedRequests.Load(),
ActiveConnections: s.activeConns.Load(), ActiveConnections: s.activeConns.Load(),
ActiveRequests: s.activeRequests.Load(), ActiveRequests: s.activeRequests.Load(),
} }
@@ -252,6 +259,7 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
select { select {
case slots <- struct{}{}: case slots <- struct{}{}:
default: default:
s.rejectedConns.Add(1)
_ = conn.Close() _ = conn.Close()
continue continue
} }
@@ -424,6 +432,7 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
s.activeRequests.Add(-1) s.activeRequests.Add(-1)
return return
default: default:
s.rejectedRequests.Add(1)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusServiceUnavailable) writer.WriteHeader(http.StatusServiceUnavailable)
writer.body.WriteString("server busy\n") writer.body.WriteString("server busy\n")

View File

@@ -711,6 +711,9 @@ func TestServerConcurrentRequestLimit(t *testing.T) {
if response.StatusCode != http.StatusServiceUnavailable { if response.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503 at concurrency limit, got %d", response.StatusCode) t.Fatalf("expected 503 at concurrency limit, got %d", response.StatusCode)
} }
if stats := server.Stats(); stats.RejectedRequests != 1 {
t.Fatalf("expected one rejected request, got %+v", stats)
}
close(release) close(release)
response, err = http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet}) response, err = http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet})
if err != nil { if err != nil {
@@ -842,6 +845,9 @@ func TestServerConnectionLimitReleasesAfterRequest(t *testing.T) {
t.Fatal("connection over MaxConnections was not rejected") t.Fatal("connection over MaxConnections was not rejected")
} }
second.Close() second.Close()
if stats := server.Stats(); stats.RejectedConnections < 1 {
t.Fatalf("expected rejected connection counter, got %+v", stats)
}
close(release) close(release)
firstResponse, err := http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet}) firstResponse, err := http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet})