mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2026-09-21 08:11:06 +09:00
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
This commit is contained in:
+92
-66
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
-17
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user