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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user