From ebd8463c1924bea956a11ec0043fcf201b5f345c Mon Sep 17 00:00:00 2001 From: dalbodeule <11470513+dalbodeule@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:58:14 +0900 Subject: [PATCH] docs: align architecture and API docs with Go 1.27 ingress support - document HTTP/1.1, HTTP/2, and HTTP/3 ingress - describe SSE streaming and timeout behavior - document HTTP/2 and HTTP/3 Extended CONNECT - update yamux stream and deployment architecture - document Go 1.27 and UDP HTTP/3 requirements --- API.md | 37 +++++++ ARCHITECTURE.md | 82 ++++++++++++--- Dockerfile.server | 4 +- README.md | 52 ++++++++-- cmd/client/yamux_tunnel.go | 83 ++++++++++++++- cmd/server/main.go | 158 ++++++++++++++++------------ cmd/server/streaming_test.go | 130 +++++++++++++++++++++++ cmd/server/yamux_tunnel.go | 194 ++++++++++++++++++++++++++++++++--- go.mod | 19 ++-- go.sum | 48 +++++---- internal/tunnel/types.go | 9 -- 11 files changed, 668 insertions(+), 148 deletions(-) create mode 100644 cmd/server/streaming_test.go diff --git a/API.md b/API.md index b807976..1b72caa 100644 --- a/API.md +++ b/API.md @@ -1,5 +1,38 @@ # HopGate API +## Public Ingress + +Registered domains are served by the public listeners configured on the server. +The same request is forwarded to the client's `HOP_CLIENT_LOCAL_TARGET`. + +| Protocol | Port | Supported behavior | +| --- | --- | --- | +| HTTP/1.1 | TCP `HOP_SERVER_HTTP_LISTEN` / `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Upgrade | +| HTTP/2 | TCP `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Extended CONNECT | +| HTTP/3 | UDP `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Extended CONNECT | + +HTTP/3 is announced to HTTP/1.1 and HTTP/2 clients with `Alt-Svc`. HTTP/2 +Extended CONNECT requires `GODEBUG=http2xconnect=1` when starting the server. + +### SSE + +SSE is detected when the request `Accept` header contains `text/event-stream`. +The response is streamed without buffering and bypasses the normal proxy +timeout. The upstream should send standard SSE records separated by a blank +line, for example: + +```text +data: hello + +``` + +### WebSocket + +HTTP/1.1 WebSocket uses `Upgrade: websocket`. HTTP/2 and HTTP/3 use Extended +CONNECT with `:protocol=websocket`. The local service may remain an ordinary +HTTP/1.1 WebSocket server; HopGate translates the Extended CONNECT handshake +before relaying the raw bidirectional payload. + ## Admin API Admin endpoints are served under `/api/v1/admin/` on `HOP_SERVER_DOMAIN` and @@ -19,3 +52,7 @@ to `:7443`. The client connects to that address with Required client settings are `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, `HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, and `HOP_CLIENT_DEBUG`. + +The tunnel is TLS over TCP with yamux multiplexing. The client only needs an +outbound TCP connection to the tunnel listener; public HTTP/3 traffic terminates +at the server and does not require QUIC support in the client. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9ff2d2..beb9d5b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,31 +1,79 @@ # HopGate Architecture HopGate exposes public HTTP traffic and forwards it to a private HTTP service -through an outbound client connection. +through one outbound TLS connection per client. ```text -public HTTP/HTTPS :80/:443 - | - v -HopGate server -- TLS/TCP :7443 -- yamux -- HopGate client -- localhost HTTP + TCP :80/:443 HTTP/1.1, HTTP/2 +public clients -------------------------------> HopGate server + UDP :443 HTTP/3 | + | TLS/TCP + v + yamux logical streams + | + v + HopGate client + | + v + localhost HTTP ``` -The client opens one TLS connection to the server and authenticates with a -yamux control stream containing the registered domain and API key. Each HTTP -request uses one bidirectional yamux stream. The stream begins with a bounded -JSON metadata record and then carries the HTTP/1.1 wire representation. +## Connection Model -The public Go HTTP server handles HTTP/1.1 and HTTP/2. HTTP/3 and WebSocket -upgrade support are planned ingress features; they can reuse the same yamux -stream abstraction. +The client opens one TLS connection to the server and the connection is +multiplexed by yamux. The first logical stream is a bounded JSON control stream +containing the registered domain, local target, and client API key. The server +authenticates this stream before registering the session for the domain. + +Each public request creates one bidirectional yamux stream. Every stream starts +with a bounded JSON `StreamMeta` record and then carries HTTP/1.1 wire data. +The stream kinds currently used are: + +- `control`: client registration and authentication metadata. +- `http`: ordinary HTTP requests and responses. +- `websocket`: HTTP/1.1 Upgrade and HTTP/2/HTTP/3 Extended CONNECT traffic. + +Request and response bodies are copied between stream endpoints instead of +being accumulated in memory. Long-lived SSE connections therefore occupy one +yamux stream for their lifetime. + +## Ingress Protocols + +The public server uses one common `http.Handler` for all ingress protocols: + +- HTTP/1.1: ordinary reverse proxy and raw WebSocket Upgrade. +- HTTP/2: ordinary reverse proxy, SSE, and RFC 8441 Extended CONNECT. +- HTTP/3: ordinary reverse proxy, SSE, and RFC 9220 Extended CONNECT. + +HTTP/3 runs on a separate UDP listener using `quic-go/http3`, while the TCP +HTTP/HTTPS listeners continue to serve HTTP/1.1 and HTTP/2. HTTP/1.1 and HTTP/2 +responses advertise HTTP/3 with `Alt-Svc`. + +For HTTP/2 Extended CONNECT, Go's compatibility setting must be enabled when +starting the process: + +```bash +GODEBUG=http2xconnect=1 ./bin/hop-gate-server +``` + +## Streaming Policies + +Requests accepting `text/event-stream` are treated as SSE. They bypass the +normal request-level proxy timeout, and response writes are flushed to the +public client as they arrive. The client or upstream service is responsible for +closing the SSE request context. + +WebSocket Extended CONNECT is translated to a local HTTP/1.1 WebSocket +handshake. After the handshake, the payload is relayed as a bidirectional raw +stream. HTTP/3 Extended CONNECT follows the same application path as HTTP/2. ## Packages -- `internal/tunnel`: TLS dialing, yamux sessions, stream metadata, and responses. -- `cmd/server`: public HTTP/HTTPS ingress and yamux tunnel listener. -- `cmd/client`: outbound yamux client and local HTTP forwarding. +- `internal/tunnel`: TLS dialing, yamux sessions, metadata, and stream lifecycle. +- `cmd/server`: public HTTP/HTTPS/HTTP/3 ingress and yamux tunnel listener. +- `cmd/client`: outbound yamux client and local HTTP/WebSocket forwarding. - `internal/admin`: domain registration and API-key validation. -- `internal/acme`: certificate acquisition and renewal. +- `internal/acme`: certificate acquisition, renewal, and TLS configuration. The tunnel is intentionally stream-oriented. It does not implement application -ACKs or retransmission; TCP and yamux provide ordered reliable delivery. +ACKs or retransmission; TLS over TCP and yamux provide ordered reliable delivery. diff --git a/Dockerfile.server b/Dockerfile.server index dc7792c..c8f777d 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -12,7 +12,7 @@ # hop-gate-server:dev # ---------- Build stage ---------- -FROM golang:1.25-alpine AS builder +FROM golang:1.27-alpine AS builder # BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다. # 기본값을 지정해두면 로컬 docker build 시에도 별도 인자 없이 빌드 가능합니다. @@ -38,7 +38,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-X main.version=${VERSION}" -o /out/hop-gate-server ./cmd/server # ---------- Runtime stage ---------- -FROM alpine:3.20 +FROM alpine:3.24 WORKDIR /app diff --git a/README.md b/README.md index 28f7510..0fb39ea 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ HopGate is a gateway that provides a **TLS + yamux HTTP tunnel** between a publi - 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다. Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack. -> 참고: 현재 yamux logical stream은 HTTP/1.1 wire format을 사용하며, 큰 body는 향후 `io.Pipe` 기반 streaming으로 개선할 예정입니다. (ko) -> Note: yamux logical streams currently use HTTP/1.1 wire format; large bodies will be improved with `io.Pipe`-based streaming. (en) +> 참고: yamux logical stream은 HTTP/1.1 wire format을 사용하지만, 요청과 응답 body는 버퍼 전체를 메모리에 올리지 않고 스트리밍됩니다. SSE는 연결이 유지되는 동안 이벤트를 즉시 전달합니다. (ko) +> Note: yamux logical streams use HTTP/1.1 wire format, while request and response bodies are streamed without buffering the entire payload in memory. SSE events are delivered while the connection remains open. (en) 아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다. Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md). @@ -41,8 +41,8 @@ Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md). ### 3.1 의존성 (Dependencies) -- Go 1.21+ 권장 (go.mod 상 버전보다 최신 Go 사용을 추천) - Go 1.21+ is recommended (even if go.mod specifies an older minor). +- Go 1.27.0+ 필요 + Go 1.27.0 or newer is required. - PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수) PostgreSQL (required for the admin plane and the real DomainValidator). @@ -169,6 +169,17 @@ HOP_CLIENT_DEBUG=true ./bin/hop-gate-client ``` +HTTP/3 ingress를 사용하려면 서버의 TCP HTTPS 포트와 동일한 UDP 포트를 외부에 노출해야 합니다. +HTTP/3 ingress requires exposing the same port as the HTTPS listener over UDP. + +HTTP/2 Extended CONNECT를 사용하는 클라이언트가 있는 경우 Go HTTP/2의 +호환성 설정을 켜고 서버를 실행합니다. +For HTTP/2 Extended CONNECT clients, enable Go's compatibility setting: + +```bash +GODEBUG=http2xconnect=1 ./bin/hop-gate-server +``` + 성공 시 로그에는 다음과 같은 정보가 찍힙니다. On success, logs will include information like: @@ -210,8 +221,37 @@ For implementation skeleton, see [`internal/admin`](internal/admin) and [`ent/sc - `Debug=true` 설정은 **개발/테스트 용도**입니다. self-signed 인증서 및 InsecureSkipVerify 사용은 프로덕션 환경에서 절대 사용하지 마세요. `Debug=true` is strictly for development/testing. Do not use self-signed certs or InsecureSkipVerify in production. -- 현재 yamux 경로는 HTTP/1.1·HTTP/2 공개 요청을 처리하지만, WebSocket raw upgrade와 HTTP/3 ingress는 아직 구현 대상입니다. - The yamux path handles public HTTP/1.1 and HTTP/2 requests; WebSocket raw upgrade and HTTP/3 ingress remain future work. +- 현재 yamux 경로는 HTTP/1.1·HTTP/2·HTTP/3 공개 요청, SSE, HTTP/1.1 WebSocket raw upgrade와 HTTP/2·HTTP/3 Extended CONNECT를 처리합니다. + The yamux path handles public HTTP/1.1, HTTP/2, and HTTP/3 requests, SSE, HTTP/1.1 WebSocket raw upgrade, and HTTP/2 and HTTP/3 Extended CONNECT. + +### Supported Ingress Protocols + +| Ingress | 일반 HTTP | SSE | WebSocket 방식 | +| --- | --- | --- | --- | +| HTTP/1.1 | 지원 | 지원 | HTTP/1.1 Upgrade | +| HTTP/2 | 지원 | 지원 | Extended CONNECT | +| HTTP/3 | 지원 | 지원 | Extended CONNECT | + +모든 ingress는 동일한 TLS + yamux 터널을 통해 클라이언트의 로컬 HTTP 서비스로 전달됩니다. +All ingress protocols use the same TLS + yamux tunnel to reach the client's local HTTP service. + +### SSE and Extended CONNECT WebSocket + +SSE responses are streamed through the yamux stream and do not use the normal +proxy timeout when the request accepts `text/event-stream`. This policy applies +to HTTP/1.1, HTTP/2, and HTTP/3 ingress alike; the client is responsible for +closing the request context when the SSE connection should end. + +HTTP/2 WebSocket Extended CONNECT is enabled by the Go HTTP/2 implementation +with the following process setting: + +```bash +GODEBUG=http2xconnect=1 go run ./cmd/server +``` + +The Extended CONNECT path translates the HTTP/2 or HTTP/3 WebSocket handshake +to the existing local HTTP/1.1 WebSocket connector, then relays the +bidirectional stream through yamux. HopGate는 아직 초기 단계의 실험적 프로젝트입니다. API 및 동작은 언제든지 변경될 수 있습니다. HopGate is still experimental; APIs and behavior may change at any time. diff --git a/cmd/client/yamux_tunnel.go b/cmd/client/yamux_tunnel.go index cca563e..d2061fe 100644 --- a/cmd/client/yamux_tunnel.go +++ b/cmd/client/yamux_tunnel.go @@ -6,14 +6,17 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "io" "net" "net/http" "net/url" + "strings" "time" "github.com/dalbodeule/hop-gate/internal/config" "github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/tunnel" + "github.com/gorilla/websocket" ) func runYamuxTunnelClient(ctx context.Context, logger logging.Logger, cfg *config.ClientConfig) error { @@ -62,12 +65,20 @@ func runYamuxTunnelClient(ctx context.Context, logger logging.Logger, cfg *confi if err != nil { return err } - go handleYamuxHTTPStream(stream, client, localBase, logger) + go handleYamuxHTTPStream(ctx, stream, client, localBase, logger) } } -func handleYamuxHTTPStream(stream *tunnel.Stream, client *http.Client, localBase *url.URL, logger logging.Logger) { +func handleYamuxHTTPStream(ctx context.Context, stream *tunnel.Stream, client *http.Client, localBase *url.URL, logger logging.Logger) { defer stream.Close() + if stream.Meta.Kind == "websocket" { + handleYamuxWebSocketStream(ctx, stream, localBase, logger) + return + } + if stream.Meta.Kind != "http" { + logger.Warn("unsupported yamux stream kind", logging.Fields{"kind": stream.Meta.Kind}) + return + } request, err := http.ReadRequest(bufio.NewReader(stream)) if err != nil { logger.Warn("read HTTP request from yamux stream failed", logging.Fields{"error": err.Error()}) @@ -94,7 +105,73 @@ func handleYamuxHTTPStream(stream *tunnel.Stream, client *http.Client, localBase return } defer response.Body.Close() - if err := response.Write(stream); err != nil { + if err := writeHTTPResponse(stream, response); err != nil { logger.Warn("write local HTTP response to yamux stream failed", logging.Fields{"error": err.Error()}) } } + +func writeHTTPResponse(stream io.Writer, response *http.Response) error { + return response.Write(stream) +} + +func handleYamuxWebSocketStream(ctx context.Context, stream *tunnel.Stream, localBase *url.URL, logger logging.Logger) { + request, err := http.ReadRequest(bufio.NewReader(stream)) + if err != nil { + logger.Warn("read WebSocket request from yamux stream failed", logging.Fields{"error": err.Error()}) + return + } + request.URL.Scheme = "ws" + request.URL.Host = localBase.Host + request.RequestURI = "" + + header := make(http.Header) + var subprotocols []string + for key, values := range request.Header { + switch http.CanonicalHeaderKey(key) { + case "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions": + continue + case "Sec-Websocket-Protocol": + for _, value := range values { + for _, protocol := range strings.Split(value, ",") { + if strings.TrimSpace(protocol) != "" { + subprotocols = append(subprotocols, strings.TrimSpace(protocol)) + } + } + } + default: + header[key] = append([]string(nil), values...) + } + } + dialer := websocket.Dialer{Subprotocols: subprotocols, HandshakeTimeout: 10 * time.Second} + backend, response, err := dialer.DialContext(ctx, request.URL.String(), header) + if err != nil { + logger.Warn("dial local WebSocket failed", logging.Fields{"error": err.Error()}) + failure := &http.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}, + Body: http.NoBody, + } + _ = failure.Write(stream) + return + } + defer backend.Close() + if err := response.Write(stream); err != nil { + logger.Warn("write WebSocket handshake to server failed", logging.Fields{"error": err.Error()}) + return + } + + backendConn := backend.UnderlyingConn() + result := make(chan error, 2) + go func() { + _, err := io.Copy(stream, backendConn) + result <- err + }() + go func() { + _, err := io.Copy(backendConn, stream) + result <- err + }() + <-result +} diff --git a/cmd/server/main.go b/cmd/server/main.go index ce47f08..d119ecd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,7 +1,9 @@ package main import ( + "bufio" "context" + "fmt" "io" stdfs "io/fs" "net" @@ -14,6 +16,7 @@ import ( "time" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/quic-go/quic-go/http3" "github.com/dalbodeule/hop-gate/internal/acme" "github.com/dalbodeule/hop-gate/internal/admin" @@ -22,7 +25,6 @@ import ( "github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/observability" "github.com/dalbodeule/hop-gate/internal/store" - "github.com/dalbodeule/hop-gate/internal/tunnel" ) var version = "dev" @@ -42,7 +44,11 @@ var ( ) type forwardTunnel interface { - ForwardHTTP(context.Context, logging.Logger, *http.Request, string) (*tunnel.Response, error) + ForwardHTTP(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error +} + +type extendedConnectForwarder interface { + ForwardExtendedConnect(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error } func registerTunnelForDomain(domain string, sess forwardTunnel, logger logging.Logger) string { @@ -135,10 +141,36 @@ func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Ha } func (w *statusRecorder) WriteHeader(code int) { + if w.status != 0 { + return + } w.status = code w.ResponseWriter.WriteHeader(code) } +func (w *statusRecorder) Write(p []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(p) +} + +func (w *statusRecorder) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("underlying response writer does not support hijacking") + } + return hijacker.Hijack() +} + +func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter } + func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Handler { // ACME webroot (for HTTP-01) is read from env; must match HOP_ACME_WEBROOT used by lego. webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT")) @@ -176,7 +208,7 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand // 상태 코드 캡처를 위한 래퍼 sr := &statusRecorder{ ResponseWriter: w, - status: http.StatusOK, + status: 0, } // 보안/식별 헤더를 공통으로 설정합니다. (ko) // Configure common security and identity headers. (en) @@ -312,78 +344,52 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand // HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the tunnel forward path so that // excessively slow backends surface as gateway timeouts. (en) ctx := r.Context() - if proxyTimeout > 0 { + if proxyTimeout > 0 && !isSSERequest(r) { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, proxyTimeout) defer cancel() } - type forwardResult struct { - resp *tunnel.Response - err error - } - resultCh := make(chan forwardResult, 1) - - go func() { - select { - case <-ctx.Done(): - // Context cancelled, do not proceed. + if isExtendedConnectWebSocketRequest(r) { + extendedTunnel, ok := activeTunnel.(extendedConnectForwarder) + if !ok { + writeErrorPage(sr, r, http.StatusNotImplemented) return - default: - resp, err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName) - resultCh <- forwardResult{resp: resp, err: err} } - }() - - var protoResp *tunnel.Response - - select { - case <-ctx.Done(): - log.Error("forward over tunnel timed out", logging.Fields{ - "timeout_seconds": int64(proxyTimeout.Seconds()), - "error": ctx.Err().Error(), - }) - observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc() - writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) + if err := extendedTunnel.ForwardExtendedConnect(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("HTTP/2 Extended CONNECT forwarding failed", logging.Fields{"error": err.Error()}) + writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) + } return + } - case res := <-resultCh: - if res.err != nil { - log.Error("forward over tunnel failed", logging.Fields{ - "error": res.err.Error(), - }) + if isWebSocketRequest(r) { + wsTunnel, ok := activeTunnel.(websocketForwarder) + if !ok { + writeErrorPage(sr, r, http.StatusNotImplemented) + return + } + if err := wsTunnel.ForwardWebSocket(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("WebSocket forwarding failed", logging.Fields{"error": err.Error()}) + writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) + } + return + } + + if err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("forward over tunnel failed", logging.Fields{"error": err.Error()}) + if ctx.Err() != nil { + observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc() + writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) + } else { observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc() writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) - return - } - protoResp = res.resp - } - - // 응답 헤더/바디 복원 - for k, vs := range protoResp.Header { - // HopGate 가 소유한 보안/식별 헤더는 백엔드 값 대신 서버 값만 사용합니다. (ko) - // For security/identity headers owned by HopGate, ignore backend values. (en) - if _, ok := hopGateOwnedHeaders[http.CanonicalHeaderKey(k)]; ok { - continue - } - for _, v := range vs { - sr.Header().Add(k, v) - } - } - if protoResp.Status == 0 { - protoResp.Status = http.StatusOK - } - sr.WriteHeader(protoResp.Status) - if len(protoResp.Body) > 0 { - if _, err := sr.Write(protoResp.Body); err != nil { - log.Warn("failed to write http response body", logging.Fields{ - "error": err.Error(), - }) } + return } log.Info("http request completed", logging.Fields{ - "status": protoResp.Status, + "status": sr.status, "elapsed_ms": time.Since(start).Milliseconds(), "service_name": serviceName, }) @@ -573,6 +579,24 @@ func main() { // 기본 HTTP → yamux Proxy 엔트리 포인트 httpMux.Handle("/", httpHandler) + // HTTP/3 uses the same ingress handler and certificates as HTTPS, but listens + // on UDP separately from the TCP listener. + if len(acmeTLSCfg.NextProtos) == 0 { + acmeTLSCfg.NextProtos = []string{"h2", "http/1.1"} + } + http3Server := &http3.Server{ + Addr: cfg.HTTPSListen, + Handler: nil, + TLSConfig: http3.ConfigureTLSConfig(acmeTLSCfg.Clone()), + } + publicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor < 3 { + _ = http3Server.SetQUICHeaders(w.Header()) + } + httpMux.ServeHTTP(w, r) + }) + http3Server.Handler = publicHandler + go func() { if err := serveYamuxTunnel(context.Background(), cfg.TunnelListen, acmeTLSCfg, logger, domainValidator); err != nil { logger.Error("yamux tunnel server stopped", logging.Fields{"error": err.Error()}) @@ -596,14 +620,9 @@ func main() { } }() - // HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능) - if len(acmeTLSCfg.NextProtos) == 0 { - acmeTLSCfg.NextProtos = []string{"h2", "http/1.1"} - } - httpsSrv := &http.Server{ Addr: cfg.HTTPSListen, - Handler: httpMux, + Handler: publicHandler, TLSConfig: acmeTLSCfg, } go func() { @@ -617,6 +636,13 @@ func main() { } }() + go func() { + logger.Info("http/3 server listening", logging.Fields{"addr": cfg.HTTPSListen}) + if err := http3Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http/3 server error", logging.Fields{"error": err.Error()}) + } + }() + // yamux 및 HTTP/HTTPS 서버 goroutine을 유지합니다. (ko) // Keep the yamux and HTTP/HTTPS server goroutines running. (en) select {} diff --git a/cmd/server/streaming_test.go b/cmd/server/streaming_test.go new file mode 100644 index 0000000..fd92379 --- /dev/null +++ b/cmd/server/streaming_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dalbodeule/hop-gate/internal/logging" +) + +type noopLogger struct{} + +func (noopLogger) Debug(string, logging.Fields) {} +func (noopLogger) Info(string, logging.Fields) {} +func (noopLogger) Warn(string, logging.Fields) {} +func (noopLogger) Error(string, logging.Fields) {} +func (l noopLogger) With(logging.Fields) logging.Logger { return l } + +type streamingTestTunnel struct { + forwardHTTPCalled bool + extendedConnectCalled bool + deadlineSeen bool +} + +func (t *streamingTestTunnel) ForwardHTTP(ctx context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error { + t.forwardHTTPCalled = true + _, t.deadlineSeen = ctx.Deadline() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("data: ready\n\n")) + return err +} + +func (t *streamingTestTunnel) ForwardExtendedConnect(_ context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error { + t.extendedConnectCalled = true + w.WriteHeader(http.StatusOK) + return nil +} + +func TestSSERequestStreamsWithoutProxyTimeout(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "sse-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodGet, "http://"+domain+"/events", nil) + req.Host = domain + req.Header.Set("Accept", "text/event-stream") + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req) + + if !tunnel.forwardHTTPCalled { + t.Fatal("expected SSE request to use HTTP forwarder") + } + if tunnel.deadlineSeen { + t.Fatal("expected SSE request to avoid the normal proxy timeout") + } + if got := recorder.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("Content-Type = %q, want text/event-stream", got) + } + if got := recorder.Body.String(); got != "data: ready\n\n" { + t.Fatalf("body = %q, want SSE event", got) + } +} + +func TestHTTP2ExtendedConnectUsesDedicatedForwarder(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "h2-connect-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodConnect, "https://"+domain+"/socket", nil) + req.Host = domain + req.ProtoMajor = 2 + req.ProtoMinor = 0 + req.Proto = "websocket" + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Second).ServeHTTP(recorder, req) + + if !tunnel.extendedConnectCalled { + t.Fatal("expected HTTP/2 Extended CONNECT forwarder to be called") + } + if tunnel.forwardHTTPCalled { + t.Fatal("did not expect regular HTTP forwarder for Extended CONNECT") + } + if got := recorder.Code; got != http.StatusOK { + t.Fatalf("status = %d, want %d", got, http.StatusOK) + } +} + +func TestHTTP3ExtendedConnectDetection(t *testing.T) { + req := httptest.NewRequest(http.MethodConnect, "https://h3.example/socket", nil) + req.ProtoMajor = 3 + req.ProtoMinor = 0 + req.Proto = "websocket" + + if !isExtendedConnectWebSocketRequest(req) { + t.Fatal("expected HTTP/3 Extended CONNECT WebSocket request to be detected") + } +} + +func TestHTTP3SSERequestUsesStreamingPolicy(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "h3-sse-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodGet, "https://"+domain+"/events", nil) + req.Host = domain + req.ProtoMajor = 3 + req.ProtoMinor = 0 + req.Header.Set("Accept", "text/event-stream") + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req) + + if tunnel.deadlineSeen { + t.Fatal("expected HTTP/3 SSE request to avoid the normal proxy timeout") + } + if got := recorder.Body.String(); got != "data: ready\n\n" { + t.Fatalf("body = %q, want SSE event", got) + } +} diff --git a/cmd/server/yamux_tunnel.go b/cmd/server/yamux_tunnel.go index 4e944af..91c7759 100644 --- a/cmd/server/yamux_tunnel.go +++ b/cmd/server/yamux_tunnel.go @@ -20,7 +20,7 @@ type yamuxTunnelSession struct { logger logging.Logger } -func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*tunnel.Response, error) { +func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { if ctx == nil { ctx = context.Background() } @@ -34,7 +34,7 @@ func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Log } stream, err := t.session.Open(ctx, meta) if err != nil { - return nil, err + return err } defer stream.Close() if deadline, ok := ctx.Deadline(); ok { @@ -44,28 +44,188 @@ func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Log request := req.Clone(ctx) request.RequestURI = "" if err := request.Write(stream); err != nil { - return nil, fmt.Errorf("write HTTP request to yamux stream: %w", err) + return fmt.Errorf("write HTTP request to yamux stream: %w", err) } resp, err := http.ReadResponse(bufio.NewReader(stream), req) if err != nil { - return nil, fmt.Errorf("read HTTP response from yamux stream: %w", err) + return fmt.Errorf("read HTTP response from yamux stream: %w", err) } defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read HTTP response body from yamux stream: %w", err) - } - result := &tunnel.Response{ - RequestID: "yamux", - Status: resp.StatusCode, - Header: make(map[string][]string, len(resp.Header)), - Body: body, - } for key, values := range resp.Header { - result.Header[key] = append([]string(nil), values...) + if _, owned := hopGateOwnedHeaders[http.CanonicalHeaderKey(key)]; owned { + continue + } + for _, value := range values { + w.Header().Add(key, value) + } } - return result, nil + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(flushingResponseWriter{ResponseWriter: w}, resp.Body); err != nil { + return fmt.Errorf("stream HTTP response body from yamux: %w", err) + } + return nil +} + +type websocketForwarder interface { + ForwardWebSocket(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error +} + +func isWebSocketRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func isExtendedConnectWebSocketRequest(r *http.Request) bool { + return r.ProtoMajor >= 2 && r.Method == http.MethodConnect && + strings.EqualFold(r.Proto, "websocket") +} + +func isSSERequest(r *http.Request) bool { + for _, value := range r.Header.Values("Accept") { + for _, mediaType := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(strings.SplitN(mediaType, ";", 2)[0]), "text/event-stream") { + return true + } + } + } + return false +} + +func (t *yamuxTunnelSession) ForwardWebSocket(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { + if ctx == nil { + ctx = context.Background() + } + if _, ok := w.(http.Hijacker); !ok { + return fmt.Errorf("websocket upgrade requires HTTP/1.1 hijacking") + } + stream, err := t.session.Open(ctx, tunnel.StreamMeta{ + Kind: "websocket", + Service: serviceName, + Method: req.Method, + Path: req.URL.RequestURI(), + Host: req.Host, + Headers: req.Header, + }) + if err != nil { + return err + } + defer stream.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = stream.SetDeadline(deadline) + } + request := req.Clone(ctx) + request.RequestURI = "" + if err := request.Write(stream); err != nil { + return fmt.Errorf("write WebSocket request to yamux stream: %w", err) + } + backendReader := bufio.NewReader(stream) + backendResponse, err := http.ReadResponse(backendReader, req) + if err != nil { + return fmt.Errorf("read WebSocket handshake from yamux stream: %w", err) + } + if backendResponse.StatusCode != http.StatusSwitchingProtocols { + defer backendResponse.Body.Close() + w.WriteHeader(backendResponse.StatusCode) + _, _ = io.Copy(w, backendResponse.Body) + return fmt.Errorf("backend rejected WebSocket upgrade with status %d", backendResponse.StatusCode) + } + + hijacker := w.(http.Hijacker) + clientConn, clientRW, err := hijacker.Hijack() + if err != nil { + return fmt.Errorf("hijack public WebSocket connection: %w", err) + } + defer clientConn.Close() + if err := backendResponse.Write(clientRW); err != nil { + return fmt.Errorf("write WebSocket handshake to public client: %w", err) + } + if err := clientRW.Flush(); err != nil { + return fmt.Errorf("flush WebSocket handshake: %w", err) + } + + return relayConnections(clientRW.Reader, stream, clientConn, backendReader) +} + +func (t *yamuxTunnelSession) ForwardExtendedConnect(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { + if ctx == nil { + ctx = context.Background() + } + stream, err := t.session.Open(ctx, tunnel.StreamMeta{ + Kind: "websocket", + Service: serviceName, + Method: req.Method, + Path: req.URL.RequestURI(), + Host: req.Host, + Headers: req.Header, + }) + if err != nil { + return err + } + defer stream.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = stream.SetDeadline(deadline) + } + + request := req.Clone(ctx) + request.Method = http.MethodGet + request.RequestURI = "" + request.Body = http.NoBody + request.ContentLength = 0 + request.Header = request.Header.Clone() + request.Header.Del(":protocol") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + if err := request.Write(stream); err != nil { + return fmt.Errorf("write translated WebSocket request to yamux stream: %w", err) + } + + backendReader := bufio.NewReader(stream) + backendResponse, err := http.ReadResponse(backendReader, request) + if err != nil { + return fmt.Errorf("read translated WebSocket handshake from yamux stream: %w", err) + } + defer backendResponse.Body.Close() + if backendResponse.StatusCode != http.StatusSwitchingProtocols { + w.WriteHeader(http.StatusBadGateway) + return fmt.Errorf("local WebSocket rejected Extended CONNECT with status %d", backendResponse.StatusCode) + } + + for key, values := range backendResponse.Header { + if _, owned := hopGateOwnedHeaders[http.CanonicalHeaderKey(key)]; owned { + continue + } + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + return relayConnections(req.Body, stream, flushingResponseWriter{ResponseWriter: w}, backendReader) +} + +type flushingResponseWriter struct{ http.ResponseWriter } + +func (w flushingResponseWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } + return n, err +} + +func relayConnections(clientReader io.Reader, stream io.Writer, clientWriter io.Writer, backend io.Reader) error { + result := make(chan error, 2) + go func() { + _, err := io.Copy(stream, clientReader) + result <- err + }() + go func() { + _, err := io.Copy(clientWriter, backend) + result <- err + }() + return <-result } func serveYamuxTunnel(ctx context.Context, address string, tlsConfig *tls.Config, logger logging.Logger, validator tunnel.DomainValidator) error { diff --git a/go.mod b/go.mod index 5c88260..23e4529 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,16 @@ module github.com/dalbodeule/hop-gate -go 1.25.4 +go 1.27.0 require ( entgo.io/ent v0.14.5 github.com/go-acme/lego/v4 v4.28.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/yamux v0.1.2 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.19.0 + github.com/quic-go/quic-go v0.62.0 ) require ( @@ -28,15 +30,16 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index caa56da..666a70a 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= @@ -48,8 +50,6 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -58,31 +58,39 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tunnel/types.go b/internal/tunnel/types.go index df0c92d..afc8a6f 100644 --- a/internal/tunnel/types.go +++ b/internal/tunnel/types.go @@ -6,12 +6,3 @@ import "context" type DomainValidator interface { ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error } - -// Response is the response returned by a tunnel transport to the public HTTP ingress. -type Response struct { - RequestID string - Status int - Header map[string][]string - Body []byte - Error string -}