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:
dalbodeule
2026-09-04 16:58:14 +09:00
parent fe1018469d
commit ebd8463c19
11 changed files with 668 additions and 148 deletions
+37
View File
@@ -1,5 +1,38 @@
# HopGate API # 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 API
Admin endpoints are served under `/api/v1/admin/` on `HOP_SERVER_DOMAIN` and 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`, Required client settings are `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`,
`HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, and `HOP_CLIENT_DEBUG`. `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.
+63 -15
View File
@@ -1,31 +1,79 @@
# HopGate Architecture # HopGate Architecture
HopGate exposes public HTTP traffic and forwards it to a private HTTP service 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 ```text
public HTTP/HTTPS :80/:443 TCP :80/:443 HTTP/1.1, HTTP/2
public clients -------------------------------> HopGate server
UDP :443 HTTP/3 |
| TLS/TCP
v
yamux logical streams
| |
v v
HopGate server -- TLS/TCP :7443 -- yamux -- HopGate client -- localhost HTTP HopGate client
|
v
localhost HTTP
``` ```
The client opens one TLS connection to the server and authenticates with a ## Connection Model
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.
The public Go HTTP server handles HTTP/1.1 and HTTP/2. HTTP/3 and WebSocket The client opens one TLS connection to the server and the connection is
upgrade support are planned ingress features; they can reuse the same yamux multiplexed by yamux. The first logical stream is a bounded JSON control stream
stream abstraction. 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 ## Packages
- `internal/tunnel`: TLS dialing, yamux sessions, stream metadata, and responses. - `internal/tunnel`: TLS dialing, yamux sessions, metadata, and stream lifecycle.
- `cmd/server`: public HTTP/HTTPS ingress and yamux tunnel listener. - `cmd/server`: public HTTP/HTTPS/HTTP/3 ingress and yamux tunnel listener.
- `cmd/client`: outbound yamux client and local HTTP forwarding. - `cmd/client`: outbound yamux client and local HTTP/WebSocket forwarding.
- `internal/admin`: domain registration and API-key validation. - `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 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.
+2 -2
View File
@@ -12,7 +12,7 @@
# hop-gate-server:dev # hop-gate-server:dev
# ---------- Build stage ---------- # ---------- Build stage ----------
FROM golang:1.25-alpine AS builder FROM golang:1.27-alpine AS builder
# BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다. # BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다.
# 기본값을 지정해두면 로컬 docker build 시에도 별도 인자 없이 빌드 가능합니다. # 기본값을 지정해두면 로컬 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 RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-X main.version=${VERSION}" -o /out/hop-gate-server ./cmd/server
# ---------- Runtime stage ---------- # ---------- Runtime stage ----------
FROM alpine:3.20 FROM alpine:3.24
WORKDIR /app WORKDIR /app
+46 -6
View File
@@ -18,8 +18,8 @@ HopGate is a gateway that provides a **TLS + yamux HTTP tunnel** between a publi
- 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다. - 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다.
Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack. 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) > 참고: yamux logical stream은 HTTP/1.1 wire format을 사용하지만, 요청과 응답 body는 버퍼 전체를 메모리에 올리지 않고 스트리밍됩니다. SSE는 연결이 유지되는 동안 이벤트를 즉시 전달합니다. (ko)
> Note: yamux logical streams currently use HTTP/1.1 wire format; large bodies will be improved with `io.Pipe`-based streaming. (en) > 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)에 정리되어 있습니다. 아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다.
Detailed architecture is documented in [`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) ### 3.1 의존성 (Dependencies)
- Go 1.21+ 권장 (go.mod 상 버전보다 최신 Go 사용을 추천) - Go 1.27.0+ 필요
Go 1.21+ is recommended (even if go.mod specifies an older minor). Go 1.27.0 or newer is required.
- PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수) - PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수)
PostgreSQL (required for the admin plane and the real DomainValidator). PostgreSQL (required for the admin plane and the real DomainValidator).
@@ -169,6 +169,17 @@ HOP_CLIENT_DEBUG=true
./bin/hop-gate-client ./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: 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` 설정은 **개발/테스트 용도**입니다. self-signed 인증서 및 InsecureSkipVerify 사용은 프로덕션 환경에서 절대 사용하지 마세요.
`Debug=true` is strictly for development/testing. Do not use self-signed certs or InsecureSkipVerify in production. `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는 아직 구현 대상입니다. - 현재 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 and HTTP/2 requests; WebSocket raw upgrade and HTTP/3 ingress remain future work. 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는 아직 초기 단계의 실험적 프로젝트입니다. API 및 동작은 언제든지 변경될 수 있습니다.
HopGate is still experimental; APIs and behavior may change at any time. HopGate is still experimental; APIs and behavior may change at any time.
+80 -3
View File
@@ -6,14 +6,17 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/dalbodeule/hop-gate/internal/config" "github.com/dalbodeule/hop-gate/internal/config"
"github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/logging"
"github.com/dalbodeule/hop-gate/internal/tunnel" "github.com/dalbodeule/hop-gate/internal/tunnel"
"github.com/gorilla/websocket"
) )
func runYamuxTunnelClient(ctx context.Context, logger logging.Logger, cfg *config.ClientConfig) error { 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 { if err != nil {
return err 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() 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)) request, err := http.ReadRequest(bufio.NewReader(stream))
if err != nil { if err != nil {
logger.Warn("read HTTP request from yamux stream failed", logging.Fields{"error": err.Error()}) 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 return
} }
defer response.Body.Close() 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()}) 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
}
+89 -63
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"bufio"
"context" "context"
"fmt"
"io" "io"
stdfs "io/fs" stdfs "io/fs"
"net" "net"
@@ -14,6 +16,7 @@ import (
"time" "time"
"github.com/prometheus/client_golang/prometheus/promhttp" "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/acme"
"github.com/dalbodeule/hop-gate/internal/admin" "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/logging"
"github.com/dalbodeule/hop-gate/internal/observability" "github.com/dalbodeule/hop-gate/internal/observability"
"github.com/dalbodeule/hop-gate/internal/store" "github.com/dalbodeule/hop-gate/internal/store"
"github.com/dalbodeule/hop-gate/internal/tunnel"
) )
var version = "dev" var version = "dev"
@@ -42,7 +44,11 @@ var (
) )
type forwardTunnel interface { 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 { 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) { func (w *statusRecorder) WriteHeader(code int) {
if w.status != 0 {
return
}
w.status = code w.status = code
w.ResponseWriter.WriteHeader(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 { 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. // 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")) webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT"))
@@ -176,7 +208,7 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
// 상태 코드 캡처를 위한 래퍼 // 상태 코드 캡처를 위한 래퍼
sr := &statusRecorder{ sr := &statusRecorder{
ResponseWriter: w, ResponseWriter: w,
status: http.StatusOK, status: 0,
} }
// 보안/식별 헤더를 공통으로 설정합니다. (ko) // 보안/식별 헤더를 공통으로 설정합니다. (ko)
// Configure common security and identity headers. (en) // 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 // HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the tunnel forward path so that
// excessively slow backends surface as gateway timeouts. (en) // excessively slow backends surface as gateway timeouts. (en)
ctx := r.Context() ctx := r.Context()
if proxyTimeout > 0 { if proxyTimeout > 0 && !isSSERequest(r) {
var cancel context.CancelFunc var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, proxyTimeout) ctx, cancel = context.WithTimeout(ctx, proxyTimeout)
defer cancel() defer cancel()
} }
type forwardResult struct { if isExtendedConnectWebSocketRequest(r) {
resp *tunnel.Response extendedTunnel, ok := activeTunnel.(extendedConnectForwarder)
err error if !ok {
writeErrorPage(sr, r, http.StatusNotImplemented)
return
} }
resultCh := make(chan forwardResult, 1) 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()})
go func() { writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
select { }
case <-ctx.Done():
// Context cancelled, do not proceed.
return return
default:
resp, err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName)
resultCh <- forwardResult{resp: resp, err: err}
} }
}()
var protoResp *tunnel.Response 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
}
select { if err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 {
case <-ctx.Done(): log.Error("forward over tunnel failed", logging.Fields{"error": err.Error()})
log.Error("forward over tunnel timed out", logging.Fields{ if ctx.Err() != nil {
"timeout_seconds": int64(proxyTimeout.Seconds()),
"error": ctx.Err().Error(),
})
observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc() observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc()
writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
return } else {
case res := <-resultCh:
if res.err != nil {
log.Error("forward over tunnel failed", logging.Fields{
"error": res.err.Error(),
})
observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc() observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc()
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
}
return 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(),
})
}
}
log.Info("http request completed", logging.Fields{ log.Info("http request completed", logging.Fields{
"status": protoResp.Status, "status": sr.status,
"elapsed_ms": time.Since(start).Milliseconds(), "elapsed_ms": time.Since(start).Milliseconds(),
"service_name": serviceName, "service_name": serviceName,
}) })
@@ -573,6 +579,24 @@ func main() {
// 기본 HTTP → yamux Proxy 엔트리 포인트 // 기본 HTTP → yamux Proxy 엔트리 포인트
httpMux.Handle("/", httpHandler) 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() { go func() {
if err := serveYamuxTunnel(context.Background(), cfg.TunnelListen, acmeTLSCfg, logger, domainValidator); err != nil { if err := serveYamuxTunnel(context.Background(), cfg.TunnelListen, acmeTLSCfg, logger, domainValidator); err != nil {
logger.Error("yamux tunnel server stopped", logging.Fields{"error": err.Error()}) 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{ httpsSrv := &http.Server{
Addr: cfg.HTTPSListen, Addr: cfg.HTTPSListen,
Handler: httpMux, Handler: publicHandler,
TLSConfig: acmeTLSCfg, TLSConfig: acmeTLSCfg,
} }
go func() { 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) // yamux 및 HTTP/HTTPS 서버 goroutine을 유지합니다. (ko)
// Keep the yamux and HTTP/HTTPS server goroutines running. (en) // Keep the yamux and HTTP/HTTPS server goroutines running. (en)
select {} select {}
+130
View File
@@ -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
View File
@@ -20,7 +20,7 @@ type yamuxTunnelSession struct {
logger logging.Logger 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 { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@@ -34,7 +34,7 @@ func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Log
} }
stream, err := t.session.Open(ctx, meta) stream, err := t.session.Open(ctx, meta)
if err != nil { if err != nil {
return nil, err return err
} }
defer stream.Close() defer stream.Close()
if deadline, ok := ctx.Deadline(); ok { 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 := req.Clone(ctx)
request.RequestURI = "" request.RequestURI = ""
if err := request.Write(stream); err != nil { 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) resp, err := http.ReadResponse(bufio.NewReader(stream), req)
if err != nil { 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() 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 { for key, values := range resp.Header {
result.Header[key] = append([]string(nil), values...) if _, owned := hopGateOwnedHeaders[http.CanonicalHeaderKey(key)]; owned {
continue
} }
return result, nil for _, value := range values {
w.Header().Add(key, value)
}
}
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 { func serveYamuxTunnel(ctx context.Context, address string, tlsConfig *tls.Config, logger logging.Logger, validator tunnel.DomainValidator) error {
+11 -8
View File
@@ -1,14 +1,16 @@
module github.com/dalbodeule/hop-gate module github.com/dalbodeule/hop-gate
go 1.25.4 go 1.27.0
require ( require (
entgo.io/ent v0.14.5 entgo.io/ent v0.14.5
github.com/go-acme/lego/v4 v4.28.1 github.com/go-acme/lego/v4 v4.28.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.2 github.com/hashicorp/yamux v0.1.2
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.19.0 github.com/prometheus/client_golang v1.19.0
github.com/quic-go/quic-go v0.62.0
) )
require ( require (
@@ -28,15 +30,16 @@ require (
github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.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/rogpeppe/go-internal v1.14.1 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )
+28 -20
View File
@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= 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/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 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 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 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 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= 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/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 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 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 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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=
-9
View File
@@ -6,12 +6,3 @@ import "context"
type DomainValidator interface { type DomainValidator interface {
ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error 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
}