From 847621d218f408fa02778e96dc265035ece97b8d Mon Sep 17 00:00:00 2001 From: k2safe Date: Mon, 21 Sep 2026 04:44:47 +0800 Subject: [PATCH] feat: add native TLS serving --- CHANGELOG.md | 1 + README.md | 2 +- ROADMAP.md | 2 +- transport/README.md | 12 ++++ transport/native/server.go | 47 ++++++++++++++++ transport/native/server_test.go | 98 +++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60f645..5fa8a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - native 支持带 trailers 的 chunked 请求体,并继续通过 `MaxBodyBytes`、读超时和头部上限控制解析内存与连接占用。 - native 响应实现 `http.Flusher`:HTTP/1.1 流式响应自动使用 chunked framing,普通响应继续自动生成 `Content-Length`。 - native 在长 Handler 运行期间监测客户端断开并取消请求 context,流式写失败同步取消;短请求延迟启动监测,管线数据保留给下一次请求。补充断开、监测退出和长连接复用验收。 +- native 增加 `ServeTLS` / `RunTLS` 和 `TLSConfig` 启动入口,App.RunWith 可以直接启用 TLS;补充真实 TLS 客户端握手、请求和优雅退出验收。 - 补充慢请求体超时、大请求体和同连接管线请求验收,确认请求体未完整读取时不会进入业务 Handler,连续请求不会串线。 - 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。 - 增加 8 并发、800 次建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。 diff --git a/README.md b/README.md index a63db2c..c9f60d9 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil { } ``` -`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。 +`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。 业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库: diff --git a/ROADMAP.md b/ROADMAP.md index c65aece..3902717 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ - native 已支持受 `MaxBodyBytes` 约束的 `Content-Length` / chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。 - native 响应已支持标准 `http.Flusher`;HTTP/1.1 流式响应使用 chunked framing,普通响应继续走 `Content-Length`,HTTP/1.0 回退到关闭连接分帧。 - native 长请求已补客户端断开及流式写失败取消,监测退出后再复用 Reader;已验收普通/流式断开、慢 Handler 后连续 Keep-Alive 和管线数据不被误消费。短请求延迟启动监测,已有管线数据时取消依赖后续写失败或 Handler 返回。 -- 已补充慢请求体超时、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux、慢客户端规模、TLS 和持续运行压测仍待在目标环境验证。 +- 已补充慢请求体超时、大请求体、HTTP/1.0 Keep-Alive、HEAD/204、管线请求和基础 TLS 客户端验收;Linux、慢客户端规模、TLS 吞吐和持续运行压测仍待在目标环境验证。 - 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。 - 已用 8 个并发 worker 完成 800 次建连/断连 race 验收;真实部署仍需补文件描述符和跨平台 socket 行为观测。 - 已具备全量 race、vet、build 验收;生产压测、长连接规模和真实部署验证仍待完成。 diff --git a/transport/README.md b/transport/README.md index c92a4b9..bef1268 100644 --- a/transport/README.md +++ b/transport/README.md @@ -38,6 +38,18 @@ if err := application.RunWith(ctx, server, ":8080"); err != nil { `omni.New()` 默认仍使用 `transport/nethttp` 路由;native 服务端是显式启用的预览实现,等协议和连接边界稳定后再考虑切换默认启动器。 +TLS 可以直接作为 `App.RunWith` 的启动配置: + +```go +server := native.New(nil) +server.TLSConfig = &tls.Config{Certificates: certificates} +if err := application.RunWith(ctx, server, ":8443"); err != nil { + log.Fatal(err) +} +``` + +需要自己持有 listener 或配置证书文件时,分别使用 `ServeTLS` 或 `RunTLS`;TLS 握手仍受 native 的读超时、连接上限和优雅关闭控制。 + ## 可移植路由规则(v0.3.0) | 项目 | 约定 | diff --git a/transport/native/server.go b/transport/native/server.go index 4496b3d..a6b7a74 100644 --- a/transport/native/server.go +++ b/transport/native/server.go @@ -10,6 +10,7 @@ import ( "bufio" "bytes" "context" + "crypto/tls" "errors" "io" "net" @@ -48,6 +49,9 @@ type Server struct { MaxBodyBytes int64 MaxConnections int MaxConcurrentRequests int + // TLSConfig enables TLS for Run and App.RunWith. ServeTLS can be used + // when the listener and TLS configuration are assembled by the caller. + TLSConfig *tls.Config mu sync.Mutex handler http.Handler @@ -115,9 +119,52 @@ func (s *Server) Run(ctx context.Context, handler http.Handler, address string) if err != nil { return err } + if s.TLSConfig != nil { + return s.ServeTLS(ctx, listener, s.TLSConfig) + } return s.Serve(ctx, listener) } +// RunTLS loads a certificate pair, listens on address and serves native HTTP +// over TLS. The server still owns the listener and follows the same shutdown +// and connection-limit rules as Run. +func (s *Server) RunTLS(ctx context.Context, handler http.Handler, address, certFile, keyFile string) error { + if handler != nil { + s.handler = handler + } + if s.handler == nil { + return errors.New("go-omni/native: nil handler") + } + if address == "" { + address = ":8443" + } + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return err + } + listener, err := net.Listen("tcp", address) + if err != nil { + return err + } + return s.ServeTLS(ctx, listener, &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + }) +} + +// ServeTLS wraps listener with a TLS listener and serves native HTTP. The +// caller owns the TLS configuration value; it must contain a certificate or +// a certificate callback accepted by crypto/tls. +func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tls.Config) error { + if listener == nil { + return errors.New("go-omni/native: nil listener") + } + if config == nil { + return errors.New("go-omni/native: nil TLS config") + } + return s.Serve(ctx, tls.NewListener(listener, config)) +} + // Serve accepts connections from listener until the context is canceled or an // unrecoverable accept error occurs. The listener is owned by the Server. func (s *Server) Serve(ctx context.Context, listener net.Listener) error { diff --git a/transport/native/server_test.go b/transport/native/server_test.go index 1de97d5..f41b398 100644 --- a/transport/native/server_test.go +++ b/transport/native/server_test.go @@ -3,9 +3,17 @@ package native_test import ( "bufio" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "errors" "fmt" "io" + "math/big" "net" "net/http" "strings" @@ -848,6 +856,96 @@ func TestAppRunWithNativeRunner(t *testing.T) { } } +func TestAppRunWithNativeTLS(t *testing.T) { + application := omni.NewWithRouter(netrouter.New()) + application.GET("/health", func(ctx *omni.Context) error { return ctx.OK("ok") }) + certificate := testCertificate(t) + server := native.New(nil) + server.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + } + address := freeAddress(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- application.RunWith(ctx, server, address) }() + + client := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // test certificate is self-signed + }} + defer client.CloseIdleConnections() + var response *http.Response + var err error + deadline := time.Now().Add(2 * time.Second) + for response == nil && time.Now().Before(deadline) { + response, err = client.Get("https://" + address + "/health") + if err != nil { + time.Sleep(10 * time.Millisecond) + } + } + if err != nil || response == nil { + t.Fatalf("TLS request failed: response=%v error=%v", response, err) + } + body, readErr := io.ReadAll(response.Body) + response.Body.Close() + if readErr != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), `"data":"ok"`) { + t.Fatalf("unexpected TLS response: status=%d body=%q error=%v", response.StatusCode, body, readErr) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("TLS RunWith did not stop") + } +} + +func testCertificate(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120)) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certificate, err := tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: mustMarshalECKey(t, key)}), + ) + if err != nil { + t.Fatal(err) + } + return certificate +} + +func mustMarshalECKey(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + return der +} + func startServer(t *testing.T, handler http.Handler) (*native.Server, net.Listener, <-chan error) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0")