Compare commits

...

9 Commits

Author SHA1 Message Date
JinU Choi
8e2f1e68cb Merge pull request #21 from dalbodeule/develop
[release] 1.0.0
2025-12-11 19:40:08 +09:00
JinU Choi
983332b3d8 Merge pull request #20 from dalbodeule/feature/grpc-tunneling
[feat] DTLS 기반 HTTP 터널을 gRPC 기반 HTTP/2 터널로 전환
2025-12-11 19:38:50 +09:00
dalbodeule
38f05db0dc [feat](client): add local HTTP proxying for gRPC-based tunnels
- Enhanced gRPC client with logic to forward incoming tunnel streams as HTTP requests to a local target.
- Implemented per-stream state management for matching StreamOpen/StreamData/StreamClose to HTTP requests/responses.
- Added mechanisms to assemble HTTP requests, send them locally, and respond via tunnel streams.
- Introduced a configurable HTTP client with proper headers and connection settings for robust forwarding.
2025-12-11 19:05:26 +09:00
dalbodeule
a41bd34179 [feat](server, errorpages): add gRPC-based tunnel session handling and favicon support
- Implemented gRPC-based tunnel sessions for multiplexing HTTP requests via `grpcTunnelSession` with features like `recvLoop`, `send`, and per-stream state management.
- Registered and unregistered tunnels for domains, replacing DTLS-based sessions for improved scalability and maintainability.
- Integrated domain validation checks during gRPC tunnel handshake with configurable validator support.
- Modified static error pages (`400.html`, `404.html`, `502.html`, `504.html`, `500.html`, `525.html`) to include favicon linking, enhancing error page presentation.
2025-12-11 18:49:56 +09:00
dalbodeule
e388e5a272 [debug](server): add temporary debug log for gRPC routing inspection
- Added a debug log in `grpcOrHTTPHandler` to output protocol, content type, host, and path information.
2025-12-11 17:10:56 +09:00
dalbodeule
d93440f4b3 [chore](docker): remove unused DTLS-related UDP port mapping
- Removed `443/udp` from `EXPOSE` in `Dockerfile.server`.
- Removed UDP port mapping for `443` in `docker-compose.yml`.
2025-12-11 17:00:36 +09:00
dalbodeule
1492a1a82c [feat](protocol): update go_package path and regen related Protobuf types
- Changed `go_package` option in `hopgate_stream.proto` to `internal/protocol/pb;pb`.
- Regenerated `hopgate_stream.pb.go` with updated package path to align with new structure.
- Added `protocol.md` documenting the gRPC-based HTTP tunneling protocol.
2025-12-11 17:00:12 +09:00
dalbodeule
64f730d2df [feat](protocol, client, server): replace DTLS with gRPC for tunnel implementation
- Introduced gRPC-based tunnel design for bi-directional communication, replacing legacy DTLS transport.
- Added `HopGateTunnel` gRPC service with client and server logic for `OpenTunnel` stream handling.
- Updated client to use gRPC tunnel exclusively, including experimental entry point for stream-based HTTP proxying.
- Removed DTLS-specific client, server, and related dependencies (`pion/dtls`).
- Adjusted `cmd/server` to route gRPC and HTTP/HTTPS traffic dynamically on shared ports.
2025-12-11 16:48:17 +09:00
dalbodeule
17839def69 [feat](docs): update ARCHITECTURE.md to reflect gRPC-based tunnel design
- Replaced legacy DTLS transport details with gRPC/HTTP2 tunnel architecture.
- Updated server and client roles to describe gRPC bi-directional stream-based request/response handling.
- Revised internal component descriptions and flow diagrams to align with gRPC-based implementation.
- Marked DTLS sections as deprecated and documented planned removal in future versions.
2025-12-11 16:07:15 +09:00
24 changed files with 1780 additions and 1575 deletions

View File

@@ -13,8 +13,13 @@ This document describes the overall architecture of the HopGate system. (en)
- 서버는 80/443 포트를 점유하고, ACME(Let's Encrypt 등)로 TLS 인증서를 자동 발급/갱신합니다. (ko) - 서버는 80/443 포트를 점유하고, ACME(Let's Encrypt 등)로 TLS 인증서를 자동 발급/갱신합니다. (ko)
- The server listens on ports 80/443 and automatically issues/renews TLS certificates using ACME (e.g. Let's Encrypt). (en) - The server listens on ports 80/443 and automatically issues/renews TLS certificates using ACME (e.g. Let's Encrypt). (en)
- 클라이언트는 DTLS를 통해 서버에 연결되고, 서버가 전달한 HTTP 요청을 로컬 서비스(127.0.0.1:PORT)에 대신 보내고 응답을 다시 서버로 전달합니다. (ko) - 전송 계층은 **TCP + TLS(HTTPS) + HTTP/2 + gRPC** 기반의 터널을 사용해 서버–클라이언트 간 HTTP 요청/응답을 멀티플렉싱합니다. (ko)
- Clients connect to the server via DTLS, forward HTTP requests to local services (127.0.0.1:PORT), and send the responses back to the server. (en) - The transport layer uses a **TCP + TLS (HTTPS) + HTTP/2 + gRPC**-based tunnel to multiplex HTTP requests/responses between server and clients. (en)
- 클라이언트는 장기 유지 gRPC bi-directional stream 을 통해 서버와 터널을 형성하고,
서버가 전달한 HTTP 요청을 로컬 서비스(127.0.0.1:PORT)에 대신 보내고 응답을 다시 서버로 전달합니다. (ko)
- Clients establish long-lived gRPC bi-directional streams as tunnels to the server,
forward HTTP requests to local services (127.0.0.1:PORT), and send responses back to the server. (en)
- 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다. (ko) - 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다. (ko)
- An admin plane (REST API) is used to register/unregister domains and issue client API keys. (en) - An admin plane (REST API) is used to register/unregister domains and issue client API keys. (en)
@@ -31,8 +36,7 @@ This document describes the overall architecture of the HopGate system. (en)
├── internal/ ├── internal/
│ ├── config/ # shared configuration loader │ ├── config/ # shared configuration loader
│ ├── acme/ # ACME certificate management │ ├── acme/ # ACME certificate management
│ ├── dtls/ # DTLS abstraction & implementation │ ├── proxy/ # HTTP proxy / tunneling core (gRPC tunnel)
│ ├── proxy/ # HTTP proxy / tunneling core
│ ├── protocol/ # server-client message protocol │ ├── protocol/ # server-client message protocol
│ ├── admin/ # admin plane HTTP handlers │ ├── admin/ # admin plane HTTP handlers
│ └── logging/ # structured logging utilities │ └── logging/ # structured logging utilities
@@ -46,11 +50,11 @@ This document describes the overall architecture of the HopGate system. (en)
### 2.1 `cmd/` ### 2.1 `cmd/`
- [`cmd/server/main.go`](cmd/server/main.go) — 서버 실행 엔트리 포인트. 서버 설정 로딩, ACME/TLS 초기화, HTTP/HTTPS/DTLS 리스너 시작을 담당합니다. (ko) - [`cmd/server/main.go`](cmd/server/main.go) — 서버 실행 엔트리 포인트. 서버 설정 로딩, ACME/TLS 초기화, HTTP/HTTPS 리스너 및 gRPC 터널 엔드포인트 시작을 담당합니다. (ko)
- [`cmd/server/main.go`](cmd/server/main.go) — Server entrypoint. Loads configuration, initializes ACME/TLS, and starts HTTP/HTTPS/DTLS listeners. (en) - [`cmd/server/main.go`](cmd/server/main.go) — Server entrypoint. Loads configuration, initializes ACME/TLS, and starts HTTP/HTTPS listeners plus the gRPC tunnel endpoint. (en)
- [`cmd/client/main.go`](cmd/client/main.go) — 클라이언트 실행 엔트리 포인트. 설정 로딩, DTLS 연결 및 핸드셰이크, 로컬 서비스 프록시 루프를 담당합니다. (ko) - [`cmd/client/main.go`](cmd/client/main.go) — 클라이언트 실행 엔트리 포인트. 설정 로딩, gRPC/HTTP2 터널 연결, 로컬 서비스 프록시 루프를 담당합니다. (ko)
- [`cmd/client/main.go`](cmd/client/main.go) — Client entrypoint. Loads configuration, performs DTLS connection and handshake, and runs the local proxy loop. (en) - [`cmd/client/main.go`](cmd/client/main.go) — Client entrypoint. Loads configuration, establishes a gRPC/HTTP2 tunnel to the server, and runs the local proxy loop. (en)
--- ---
@@ -77,32 +81,24 @@ This document describes the overall architecture of the HopGate system. (en)
- Issue/renew TLS certificates for main and proxy domains. (en) - Issue/renew TLS certificates for main and proxy domains. (en)
- HTTP-01 / TLS-ALPN-01 챌린지 처리 훅 제공. (ko) - HTTP-01 / TLS-ALPN-01 챌린지 처리 훅 제공. (ko)
- Provide hooks for HTTP-01 / TLS-ALPN-01 challenges. (en) - Provide hooks for HTTP-01 / TLS-ALPN-01 challenges. (en)
- HTTPS/DTLS 리스너에 사용할 `*tls.Config` 제공. (ko) - HTTPS 및 gRPC 터널 리스너에 사용할 `*tls.Config` 제공. (ko)
- Provide `*tls.Config` for HTTPS/DTLS listeners. (en) - Provide `*tls.Config` for HTTPS and gRPC tunnel listeners. (en)
--- ---
### 2.4 `internal/dtls` ### 2.4 (Reserved for legacy DTLS prototype)
- DTLS 통신을 추상화하고, pion/dtls 기반 구현 및 핸드셰이크 로직을 포함합니다. (ko) > 초기 버전에서 DTLS 기반 터널을 실험했으나, 현재 설계에서는 **gRPC/HTTP2 터널만** 사용합니다.
- Abstracts DTLS communication and includes a pion/dtls-based implementation plus handshake logic. (en) > DTLS 관련 코드는 점진적으로 제거하거나, 별도 브랜치/히스토리에서만 보존할 예정입니다. (ko)
> Early iterations experimented with a DTLS-based tunnel, but the current design uses **gRPC/HTTP2 tunnels only**.
- 주요 요소 / Main elements: (ko/en) > Any DTLS-related code is planned to be removed or kept only in historical branches. (en)
- `Session`, `Server`, `Client` 인터페이스 — DTLS 위의 스트림과 서버/클라이언트를 추상화. (ko)
- `Session`, `Server`, `Client` interfaces — abstract streams and server/client roles over DTLS. (en)
- `NewPionServer`, `NewPionClient` — pion/dtls 를 사용하는 실제 구현. (ko)
- `NewPionServer`, `NewPionClient` — concrete implementations using pion/dtls. (en)
- `PerformServerHandshake`, `PerformClientHandshake` — 도메인 + 클라이언트 API Key 기반 애플리케이션 레벨 핸드셰이크. (ko)
- `PerformServerHandshake`, `PerformClientHandshake` — application-level handshake based on domain + client API key. (en)
- `NewSelfSignedLocalhostConfig` — 디버그용 localhost self-signed TLS 설정을 생성. (ko)
- `NewSelfSignedLocalhostConfig` — generates a debug-only localhost self-signed TLS config. (en)
--- ---
### 2.5 `internal/protocol` ### 2.5 `internal/protocol`
- 서버와 클라이언트가 DTLS 위에서 주고받는 HTTP 요청/응답 메시지 포맷을 정의합니다. (ko) - 서버와 클라이언트가 **gRPC/HTTP2 터널 전송 계층** 위에서 주고받는 HTTP 요청/응답 및 스트림 메시지 포맷을 정의합니다. (ko)
- Defines HTTP request/response message formats exchanged over DTLS between server and clients. (en) - Defines HTTP request/response and stream message formats exchanged over the gRPC/HTTP2 tunnel transport layer. (en)
- 요청 메시지 / Request message: (ko/en) - 요청 메시지 / Request message: (ko/en)
- `RequestID`, `ClientID`, `ServiceName`, `Method`, `URL`, `Header`, `Body`. (ko/en) - `RequestID`, `ClientID`, `ServiceName`, `Method`, `URL`, `Header`, `Body`. (ko/en)
@@ -110,13 +106,15 @@ This document describes the overall architecture of the HopGate system. (en)
- 응답 메시지 / Response message: (ko/en) - 응답 메시지 / Response message: (ko/en)
- `RequestID`, `Status`, `Header`, `Body`, `Error`. (ko/en) - `RequestID`, `Status`, `Header`, `Body`, `Error`. (ko/en)
- 인코딩은 현재 JSON 을 사용하며, 각 HTTP 요청/응답을 하나의 Envelope 로 감싸 DTLS 위에서 전송합니다. (ko) - 스트림 기반 터널링을 위한 Envelope/Stream 타입: (ko/en)
- Encoding currently uses JSON, wrapping each HTTP request/response in a single Envelope sent over DTLS. (en) - [`Envelope`](internal/protocol/protocol.go:64) — 상위 메시지 컨테이너. (ko/en)
- [`StreamOpen`](internal/protocol/protocol.go:94) — 새로운 스트림 오픈 및 헤더/메타데이터 전달. (ko/en)
- [`StreamData`](internal/protocol/protocol.go:104) — 시퀀스 번호(Seq)를 가진 바디 chunk 프레임. (ko/en)
- [`StreamClose`](internal/protocol/protocol.go:143) — 스트림 종료 및 에러 정보 전달. (ko/en)
- [`StreamAck`](internal/protocol/protocol.go:117) — 선택적 재전송(Selective Retransmission)을 위한 ACK/NACK 힌트. (ko/en)
- 향후에는 `Envelope.StreamOpen` / `StreamData` / `StreamClose` 필드를 활용한 **스트림/프레임 기반 프로토콜**로 전환하여, - 이 구조는 Protobuf 기반 length-prefix 프레이밍을 사용하며, gRPC bi-di stream 의 메시지 타입으로 매핑됩니다. (ko)
대용량 HTTP 바디도 DTLS/UDP MTU 한계를 넘지 않도록 chunk 단위로 안전하게 전송할 계획입니다. (ko) - This structure uses protobuf-based length-prefixed framing and is mapped onto messages in a gRPC bi-di stream. (en)
- In the future, the plan is to move to a **stream/frame-based protocol** using `Envelope.StreamOpen` / `StreamData` / `StreamClose`,
so that large HTTP bodies can be safely chunked under DTLS/UDP MTU limits. (en)
--- ---
@@ -133,22 +131,21 @@ This document describes the overall architecture of the HopGate system. (en)
- 도메인/패스 규칙에 따라 적절한 클라이언트와 서비스로 매핑합니다. (ko) - 도메인/패스 규칙에 따라 적절한 클라이언트와 서비스로 매핑합니다. (ko)
- Map requests to appropriate clients and services based on domain/path rules. (en) - Map requests to appropriate clients and services based on domain/path rules. (en)
- 요청을 `protocol.Request` 로 직렬화하여 DTLS 세션을 통해 클라이언트로 전송합니다. (ko) - 요청/응답`internal/protocol` 의 스트림 메시지(`StreamOpen` / `StreamData` / `StreamClose` 등)로 직렬화하여
- Serialize the request as `protocol.Request` and send it over a DTLS session to the client. (en) 서버–클라이언트 간 gRPC bi-di stream 위에서 주고받습니다. (ko)
- Serialize requests/responses into stream messages from `internal/protocol` (`StreamOpen` / `StreamData` / `StreamClose`, etc.)
- 클라이언트로부터 받은 `protocol.Response` 를 HTTP 응답으로 복원하여 외부 사용자에게 반환합니다. (ko) and exchange them between server and clients over a gRPC bi-di stream. (en)
- Deserialize `protocol.Response` from the client and return it as an HTTP response to the external user. (en)
#### 클라이언트 측 역할 / Client-side role #### 클라이언트 측 역할 / Client-side role
- DTLS 채널을 통해 서버가 내려보낸 `protocol.Request` 를 수신합니다. (ko) - 서버가 gRPC 터널을 통해 내려보낸 스트림 메시지를 수신합니다. (ko)
- Receive `protocol.Request` objects sent by the server over DTLS. (en) - Receive stream messages sent by the server over the gRPC tunnel. (en)
- 로컬 HTTP 서비스(예: 127.0.0.1:8080)에 요청을 전달하고 응답을 수신합니다. (ko) - 로컬 HTTP 서비스(예: 127.0.0.1:8080)에 요청을 전달하고 응답을 수신합니다. (ko)
- Forward these requests to local HTTP services (e.g. 127.0.0.1:8080) and collect responses. (en) - Forward these requests to local HTTP services (e.g. 127.0.0.1:8080) and collect responses. (en)
- 응답을 `protocol.Response` 로 직렬화하여 DTLS 채널을 통해 서버로 전송합니다. (ko) - 응답을 동일한 gRPC bi-di stream 상의 역방향 스트림 메시지로 직렬화하여 서버로 전송합니다. (ko)
- Serialize responses as `protocol.Response` and send them back to the server over DTLS. (en) - Serialize responses as reverse-direction stream messages on the same gRPC bi-di stream and send them back to the server. (en)
--- ---
@@ -195,17 +192,21 @@ The HTTPS listener on the HopGate server receives the request. (en)
3. `proxy` 레이어가 도메인과 경로를 기반으로 이 요청을 처리할 클라이언트(예: client-1)와 해당 로컬 서비스(`service-a`)를 결정합니다. (ko) 3. `proxy` 레이어가 도메인과 경로를 기반으로 이 요청을 처리할 클라이언트(예: client-1)와 해당 로컬 서비스(`service-a`)를 결정합니다. (ko)
The `proxy` layer decides which client (e.g., client-1) and which local service (`service-a`) should handle the request, based on domain and path. (en) The `proxy` layer decides which client (e.g., client-1) and which local service (`service-a`) should handle the request, based on domain and path. (en)
4. 서버는 요청을 `protocol.Request` 구조로 직렬화하고, `dtls.Session` 을 통해 선택된 클라이언트로 전송합니다. (ko) 4. 서버는 요청을 `internal/protocol` 의 스트림 메시지(예: `StreamOpen` + 여러 `StreamData` + `StreamClose`)로 직렬화하고,
The server serializes the request into a `protocol.Request` and sends it to the selected client over a `dtls.Session`. (en) 선택된 클라이언트와 맺은 gRPC bi-di stream 을 통해 전송합니다. (ko)
The server serializes the request into stream messages from `internal/protocol` (e.g., `StreamOpen` + multiple `StreamData` + `StreamClose`)
and sends them over a gRPC bi-di stream to the selected client. (en)
5. 클라이언트의 `proxy` 레이어는 `protocol.Request` 를 수신하고, 로컬 서비스(예: 127.0.0.1:8080)에 HTTP 요청을 수행합니다. (ko) 5. 클라이언트의 `proxy` 레이어는 이 스트림 메시지들을 수신해 로컬 서비스(예: 127.0.0.1:8080)에 HTTP 요청을 수행합니다. (ko)
The clients `proxy` layer receives the `protocol.Request` and performs an HTTP request to a local service (e.g., 127.0.0.1:8080). (en) The clients `proxy` layer receives these stream messages and performs an HTTP request to a local service (e.g., 127.0.0.1:8080). (en)
6. 클라이언트는 로컬 서비스로부터 HTTP 응답을 수신하고, 이를 `protocol.Response` 로 직렬화하여 DTLS 채널을 통해 서버로 다시 전송합니다. (ko) 6. 클라이언트는 로컬 서비스로부터 HTTP 응답을 수신하고, 이를 역방향 스트림 메시지(`StreamOpen` + 여러 `StreamData` + `StreamClose`)로 직렬화하여
The client receives the HTTP response from the local service, serializes it as a `protocol.Response`, and sends it back to the server over DTLS. (en) 동일한 gRPC bi-di stream 을 통해 서버로 다시 전송합니다. (ko)
The client receives the HTTP response from the local service, serializes it as reverse-direction stream messages
(`StreamOpen` + multiple `StreamData` + `StreamClose`), and sends them back to the server over the same gRPC bi-di stream. (en)
7. 서버는 `protocol.Response` 를 디코딩하여 원래의 HTTPS 요청에 대한 HTTP 응답으로 변환한 뒤, 외부 사용자에게 반환합니다. (ko) 7. 서버는 응답 스트림 메시지를 조립해 원래의 HTTPS 요청에 대한 HTTP 응답으로 변환한 뒤, 외부 사용자에게 반환합니다. (ko)
The server decodes the `protocol.Response`, converts it back into an HTTP response, and returns it to the original external user. (en) The server reassembles the response stream messages into an HTTP response for the original HTTPS request and returns it to the external user. (en)
![architecture.jpeg](images/architecture.jpeg) ![architecture.jpeg](images/architecture.jpeg)
@@ -222,13 +223,14 @@ The server decodes the `protocol.Response`, converts it back into an HTTP respon
- `internal/acme` 에 ACME 클라이언트(certmagic 또는 lego 등)를 연결해 TLS 인증서 발급/갱신을 구현합니다. (ko) - `internal/acme` 에 ACME 클라이언트(certmagic 또는 lego 등)를 연결해 TLS 인증서 발급/갱신을 구현합니다. (ko)
- Wire an ACME client (certmagic, lego, etc.) into `internal/acme` to implement TLS certificate issuance/renewal. (en) - Wire an ACME client (certmagic, lego, etc.) into `internal/acme` to implement TLS certificate issuance/renewal. (en)
- `internal/dtls` 에서 pion/dtls 기반 DTLS 전송 계층 및 핸드셰이크를 안정화합니다. (ko) - gRPC/HTTP2 기반 터널 전송 계층을 설계/구현하고, 서버/클라이언트 모두에서 장기 유지 bi-di stream 위에
- Stabilize the pion/dtls-based DTLS transport and handshake logic in `internal/dtls`. (en) HTTP 요청/응답을 멀티플렉싱하는 로직을 추가합니다. (ko)
- Design and implement a gRPC/HTTP2-based tunnel transport layer, adding logic on both server and client to multiplex HTTP requests/responses over long-lived bi-di streams. (en)
- `internal/protocol``internal/proxy` 를 통해 실제 HTTP 터널링을 구현하고, - `internal/protocol``internal/proxy` 를 통해 실제 HTTP 터널링을 구현하고,
단일 JSON Envelope 기반 모델에서 `StreamOpen` / `StreamData` / `StreamClose` 중심의 스트림 기반 DTLS 터널링으로 전환합니다. (ko) gRPC 기반 스트림 모델이 재사용할 수 있는 논리 프로토콜로 정리합니다. (ko)
- Implement real HTTP tunneling and routing rules via `internal/protocol` and `internal/proxy`, - Implement real HTTP tunneling and routing rules via `internal/protocol` and `internal/proxy`,
and move from a single JSON-Envelope model to a stream-based DTLS tunneling model built around `StreamOpen` / `StreamData` / `StreamClose`. (en) organizing the logical protocol so that the gRPC-based stream model can reuse it. (en)
- `internal/admin` + `ent` + PostgreSQL 을 사용해 Domain 등록/해제 및 클라이언트 API Key 발급을 완성합니다. (ko) - `internal/admin` + `ent` + PostgreSQL 을 사용해 Domain 등록/해제 및 클라이언트 API Key 발급을 완성합니다. (ko)
- Complete domain registration/unregistration and client API key issuing using `internal/admin` + `ent` + PostgreSQL. (en) - Complete domain registration/unregistration and client API key issuing using `internal/admin` + `ent` + PostgreSQL. (en)

View File

@@ -52,7 +52,7 @@ COPY --from=builder /out/hop-gate-server /app/hop-gate-server
COPY .env.example /app/.env.example COPY .env.example /app/.env.example
# 기본 포트 노출 (실제 포트는 .env / 설정에 따라 변경 가능) # 기본 포트 노출 (실제 포트는 .env / 설정에 따라 변경 가능)
EXPOSE 80 443/udp 443 EXPOSE 80 443
# 기본 실행 명령 # 기본 실행 명령
ENTRYPOINT ["/app/hop-gate-server"] ENTRYPOINT ["/app/hop-gate-server"]

View File

@@ -1,18 +1,29 @@
package main package main
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"flag" "flag"
"fmt"
"io"
"net" "net"
"net/http"
"net/url"
"os" "os"
"strconv"
"strings" "strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"github.com/dalbodeule/hop-gate/internal/config" "github.com/dalbodeule/hop-gate/internal/config"
"github.com/dalbodeule/hop-gate/internal/dtls"
"github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/logging"
"github.com/dalbodeule/hop-gate/internal/proxy" "github.com/dalbodeule/hop-gate/internal/protocol"
protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb"
) )
// version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다. // version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다.
@@ -48,6 +59,639 @@ func firstNonEmpty(values ...string) string {
return "" return ""
} }
// runGRPCTunnelClient 는 gRPC 기반 터널을 사용하는 실험적 클라이언트 진입점입니다. (ko)
// runGRPCTunnelClient is an experimental entrypoint for a gRPC-based tunnel client. (en)
func runGRPCTunnelClient(ctx context.Context, logger logging.Logger, finalCfg *config.ClientConfig) error {
// TLS 설정은 기존 DTLS 클라이언트와 동일한 정책을 사용합니다. (ko)
// TLS configuration mirrors the existing DTLS client policy. (en)
var tlsCfg *tls.Config
if finalCfg.Debug {
tlsCfg = &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
} else {
rootCAs, err := x509.SystemCertPool()
if err != nil || rootCAs == nil {
rootCAs = x509.NewCertPool()
}
tlsCfg = &tls.Config{
RootCAs: rootCAs,
MinVersion: tls.VersionTLS12,
}
}
// finalCfg.ServerAddr 가 "host:port" 형태이므로, SNI 에는 DNS(host) 부분만 넣어야 한다.
host := finalCfg.ServerAddr
if h, _, err := net.SplitHostPort(finalCfg.ServerAddr); err == nil && strings.TrimSpace(h) != "" {
host = h
}
tlsCfg.ServerName = host
creds := credentials.NewTLS(tlsCfg)
log := logger.With(logging.Fields{
"component": "grpc_tunnel_client",
"server_addr": finalCfg.ServerAddr,
"domain": finalCfg.Domain,
"local_target": finalCfg.LocalTarget,
})
log.Info("dialing grpc tunnel", nil)
conn, err := grpc.DialContext(ctx, finalCfg.ServerAddr, grpc.WithTransportCredentials(creds), grpc.WithBlock())
if err != nil {
log.Error("failed to dial grpc tunnel server", logging.Fields{
"error": err.Error(),
})
return err
}
defer conn.Close()
client := protocolpb.NewHopGateTunnelClient(conn)
stream, err := client.OpenTunnel(ctx)
if err != nil {
log.Error("failed to open grpc tunnel stream", logging.Fields{
"error": err.Error(),
})
return err
}
log.Info("grpc tunnel stream opened", nil)
// 초기 핸드셰이크: 도메인, API 키, 로컬 타깃 정보를 StreamOpen 헤더로 전송합니다. (ko)
// Initial handshake: send domain, API key, and local target via StreamOpen headers. (en)
headers := map[string]*protocolpb.HeaderValues{
"X-HopGate-Domain": {Values: []string{finalCfg.Domain}},
"X-HopGate-API-Key": {Values: []string{finalCfg.ClientAPIKey}},
"X-HopGate-Local-Target": {Values: []string{finalCfg.LocalTarget}},
}
open := &protocolpb.StreamOpen{
Id: "control-0",
ServiceName: "control",
TargetAddr: "",
Header: headers,
}
env := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: open,
},
}
if err := stream.Send(env); err != nil {
log.Error("failed to send initial stream_open handshake", logging.Fields{
"error": err.Error(),
})
return err
}
log.Info("sent initial stream_open handshake on grpc tunnel", logging.Fields{
"domain": finalCfg.Domain,
"local_target": finalCfg.LocalTarget,
"api_key_mask": maskAPIKey(finalCfg.ClientAPIKey),
})
// 로컬 HTTP 프록시용 HTTP 클라이언트 구성. (ko)
// HTTP client used to forward requests to the local target. (en)
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
// 서버→클라이언트 방향 StreamOpen/StreamData/StreamClose 를
// HTTP 요청 단위로 모으기 위한 per-stream 상태 테이블입니다. (ko)
// Per-stream state table to assemble HTTP requests from StreamOpen/Data/Close. (en)
type inboundStream struct {
open *protocolpb.StreamOpen
body bytes.Buffer
}
streams := make(map[string]*inboundStream)
var streamsMu sync.Mutex
// gRPC 스트림에 대한 Send 는 동시 호출이 안전하지 않으므로, sendMu 로 직렬화합니다. (ko)
// gRPC streaming Send is not safe for concurrent calls; protect with a mutex. (en)
var sendMu sync.Mutex
sendEnv := func(e *protocolpb.Envelope) error {
sendMu.Lock()
defer sendMu.Unlock()
return stream.Send(e)
}
// 서버에서 전달된 StreamOpen/StreamData/StreamClose 를 로컬 HTTP 요청으로 변환하고,
// 응답을 StreamOpen/StreamData/StreamClose 로 다시 서버에 전송하는 헬퍼입니다. (ko)
// handleStream forwards a single logical HTTP request to the local target
// and sends the response back as StreamOpen/StreamData/StreamClose frames. (en)
handleStream := func(so *protocolpb.StreamOpen, body []byte) {
go func() {
streamID := strings.TrimSpace(so.Id)
if streamID == "" {
log.Error("inbound stream has empty id", logging.Fields{})
return
}
if finalCfg.LocalTarget == "" {
log.Error("local target is empty; cannot forward request", logging.Fields{
"stream_id": streamID,
})
return
}
// Pseudo-headers 에서 메서드/URL/Host 추출. (ko)
// Extract method/URL/host from pseudo-headers. (en)
method := http.MethodGet
if hv, ok := so.Header[protocol.HeaderKeyMethod]; ok && hv != nil && len(hv.Values) > 0 && strings.TrimSpace(hv.Values[0]) != "" {
method = hv.Values[0]
}
urlStr := "/"
if hv, ok := so.Header[protocol.HeaderKeyURL]; ok && hv != nil && len(hv.Values) > 0 && strings.TrimSpace(hv.Values[0]) != "" {
urlStr = hv.Values[0]
}
u, err := url.Parse(urlStr)
if err != nil {
errMsg := fmt.Sprintf("parse url from stream_open: %v", err)
log.Error("failed to parse url from stream_open", logging.Fields{
"stream_id": streamID,
"error": err.Error(),
})
respHeader := map[string]*protocolpb.HeaderValues{
"Content-Type": {
Values: []string{"text/plain; charset=utf-8"},
},
protocol.HeaderKeyStatus: {
Values: []string{strconv.Itoa(http.StatusBadGateway)},
},
}
respOpen := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: &protocolpb.StreamOpen{
Id: streamID,
ServiceName: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: respHeader,
},
},
}
if err2 := sendEnv(respOpen); err2 != nil {
log.Error("failed to send error stream_open from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
return
}
dataEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: &protocolpb.StreamData{
Id: streamID,
Seq: 0,
Data: []byte("HopGate client: " + errMsg),
},
},
}
if err2 := sendEnv(dataEnv); err2 != nil {
log.Error("failed to send error stream_data from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
return
}
closeEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: &protocolpb.StreamClose{
Id: streamID,
Error: errMsg,
},
},
}
if err2 := sendEnv(closeEnv); err2 != nil {
log.Error("failed to send error stream_close from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
}
return
}
u.Scheme = "http"
u.Host = finalCfg.LocalTarget
// 로컬 HTTP 요청용 헤더 구성 (pseudo-headers 제거). (ko)
// Build local HTTP headers, stripping pseudo-headers. (en)
httpHeader := make(http.Header, len(so.Header))
for k, hv := range so.Header {
if k == protocol.HeaderKeyMethod ||
k == protocol.HeaderKeyURL ||
k == protocol.HeaderKeyHost ||
k == protocol.HeaderKeyStatus {
continue
}
if hv == nil {
continue
}
for _, v := range hv.Values {
httpHeader.Add(k, v)
}
}
var reqBody io.Reader
if len(body) > 0 {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
errMsg := fmt.Sprintf("create http request from stream: %v", err)
log.Error("failed to create local http request", logging.Fields{
"stream_id": streamID,
"error": err.Error(),
})
respHeader := map[string]*protocolpb.HeaderValues{
"Content-Type": {
Values: []string{"text/plain; charset=utf-8"},
},
protocol.HeaderKeyStatus: {
Values: []string{strconv.Itoa(http.StatusBadGateway)},
},
}
respOpen := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: &protocolpb.StreamOpen{
Id: streamID,
ServiceName: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: respHeader,
},
},
}
if err2 := sendEnv(respOpen); err2 != nil {
log.Error("failed to send error stream_open from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
return
}
dataEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: &protocolpb.StreamData{
Id: streamID,
Seq: 0,
Data: []byte("HopGate client: " + errMsg),
},
},
}
if err2 := sendEnv(dataEnv); err2 != nil {
log.Error("failed to send error stream_data from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
return
}
closeEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: &protocolpb.StreamClose{
Id: streamID,
Error: errMsg,
},
},
}
if err2 := sendEnv(closeEnv); err2 != nil {
log.Error("failed to send error stream_close from client", logging.Fields{
"stream_id": streamID,
"error": err2.Error(),
})
}
return
}
req.Header = httpHeader
if len(body) > 0 {
req.ContentLength = int64(len(body))
}
start := time.Now()
logReq := log.With(logging.Fields{
"component": "grpc_client_proxy",
"stream_id": streamID,
"service": so.ServiceName,
"method": method,
"url": urlStr,
"local_target": finalCfg.LocalTarget,
})
logReq.Info("forwarding stream http request to local target", nil)
res, err := httpClient.Do(req)
if err != nil {
errMsg := fmt.Sprintf("perform local http request: %v", err)
logReq.Error("local http request failed", logging.Fields{
"error": err.Error(),
})
respHeader := map[string]*protocolpb.HeaderValues{
"Content-Type": {
Values: []string{"text/plain; charset=utf-8"},
},
protocol.HeaderKeyStatus: {
Values: []string{strconv.Itoa(http.StatusBadGateway)},
},
}
respOpen := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: &protocolpb.StreamOpen{
Id: streamID,
ServiceName: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: respHeader,
},
},
}
if err2 := sendEnv(respOpen); err2 != nil {
logReq.Error("failed to send error stream_open from client", logging.Fields{
"error": err2.Error(),
})
return
}
dataEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: &protocolpb.StreamData{
Id: streamID,
Seq: 0,
Data: []byte("HopGate client: " + errMsg),
},
},
}
if err2 := sendEnv(dataEnv); err2 != nil {
logReq.Error("failed to send error stream_data from client", logging.Fields{
"error": err2.Error(),
})
return
}
closeEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: &protocolpb.StreamClose{
Id: streamID,
Error: errMsg,
},
},
}
if err2 := sendEnv(closeEnv); err2 != nil {
logReq.Error("failed to send error stream_close from client", logging.Fields{
"error": err2.Error(),
})
}
return
}
defer res.Body.Close()
// 응답 헤더 맵을 복사하고 상태 코드를 pseudo-header 로 추가합니다. (ko)
// Copy response headers and attach status code as a pseudo-header. (en)
respHeader := make(map[string]*protocolpb.HeaderValues, len(res.Header)+1)
for k, vs := range res.Header {
hv := &protocolpb.HeaderValues{
Values: append([]string(nil), vs...),
}
respHeader[k] = hv
}
statusCode := res.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
respHeader[protocol.HeaderKeyStatus] = &protocolpb.HeaderValues{
Values: []string{strconv.Itoa(statusCode)},
}
respOpen := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: &protocolpb.StreamOpen{
Id: streamID,
ServiceName: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: respHeader,
},
},
}
if err := sendEnv(respOpen); err != nil {
logReq.Error("failed to send stream response open envelope from client", logging.Fields{
"error": err.Error(),
})
return
}
// 응답 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko)
// Chunk the response body into 4KiB (StreamChunkSize) StreamData frames. (en)
buf := make([]byte, protocol.StreamChunkSize)
var seq uint64
for {
n, err := res.Body.Read(buf)
if n > 0 {
dataCopy := append([]byte(nil), buf[:n]...)
dataEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: &protocolpb.StreamData{
Id: streamID,
Seq: seq,
Data: dataCopy,
},
},
}
if err2 := sendEnv(dataEnv); err2 != nil {
logReq.Error("failed to send stream response data envelope from client", logging.Fields{
"error": err2.Error(),
})
return
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
logReq.Error("failed to read local http response body", logging.Fields{
"error": err.Error(),
})
break
}
}
closeEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: &protocolpb.StreamClose{
Id: streamID,
Error: "",
},
},
}
if err := sendEnv(closeEnv); err != nil {
logReq.Error("failed to send stream response close envelope from client", logging.Fields{
"error": err.Error(),
})
return
}
logReq.Info("stream http response sent from client", logging.Fields{
"status": statusCode,
"elapsed_ms": time.Since(start).Milliseconds(),
"error": "",
})
}()
}
// 수신 루프: 서버에서 들어오는 StreamOpen/StreamData/StreamClose 를
// 로컬 HTTP 요청으로 변환하고 응답을 다시 터널로 전송합니다. (ko)
// Receive loop: convert incoming StreamOpen/StreamData/StreamClose into local
// HTTP requests and send responses back over the tunnel. (en)
for {
if ctx.Err() != nil {
log.Info("context cancelled, closing grpc tunnel client", logging.Fields{
"error": ctx.Err().Error(),
})
return ctx.Err()
}
in, err := stream.Recv()
if err != nil {
if err == io.EOF {
log.Info("grpc tunnel stream closed by server", nil)
return nil
}
log.Error("grpc tunnel receive error", logging.Fields{
"error": err.Error(),
})
return err
}
payloadType := "unknown"
switch payload := in.Payload.(type) {
case *protocolpb.Envelope_HttpRequest:
payloadType = "http_request"
case *protocolpb.Envelope_HttpResponse:
payloadType = "http_response"
case *protocolpb.Envelope_StreamOpen:
payloadType = "stream_open"
so := payload.StreamOpen
if so == nil {
log.Error("received stream_open with nil payload on grpc tunnel client", logging.Fields{})
continue
}
streamID := strings.TrimSpace(so.Id)
if streamID == "" {
log.Error("received stream_open with empty stream id on grpc tunnel client", logging.Fields{})
continue
}
streamsMu.Lock()
if _, exists := streams[streamID]; exists {
log.Error("received duplicate stream_open for existing stream on grpc tunnel client", logging.Fields{
"stream_id": streamID,
})
streamsMu.Unlock()
continue
}
streams[streamID] = &inboundStream{open: so}
streamsMu.Unlock()
case *protocolpb.Envelope_StreamData:
payloadType = "stream_data"
sd := payload.StreamData
if sd == nil {
log.Error("received stream_data with nil payload on grpc tunnel client", logging.Fields{})
continue
}
streamID := strings.TrimSpace(sd.Id)
if streamID == "" {
log.Error("received stream_data with empty stream id on grpc tunnel client", logging.Fields{})
continue
}
streamsMu.Lock()
st := streams[streamID]
streamsMu.Unlock()
if st == nil {
log.Warn("received stream_data for unknown stream on grpc tunnel client", logging.Fields{
"stream_id": streamID,
})
continue
}
if len(sd.Data) > 0 {
if _, err := st.body.Write(sd.Data); err != nil {
log.Error("failed to buffer stream_data body on grpc tunnel client", logging.Fields{
"stream_id": streamID,
"error": err.Error(),
})
}
}
case *protocolpb.Envelope_StreamClose:
payloadType = "stream_close"
sc := payload.StreamClose
if sc == nil {
log.Error("received stream_close with nil payload on grpc tunnel client", logging.Fields{})
continue
}
streamID := strings.TrimSpace(sc.Id)
if streamID == "" {
log.Error("received stream_close with empty stream id on grpc tunnel client", logging.Fields{})
continue
}
streamsMu.Lock()
st := streams[streamID]
if st != nil {
delete(streams, streamID)
}
streamsMu.Unlock()
if st == nil {
log.Warn("received stream_close for unknown stream on grpc tunnel client", logging.Fields{
"stream_id": streamID,
})
continue
}
// 현재까지 수신한 메타데이터/바디를 사용해 로컬 HTTP 요청을 수행하고,
// 응답을 다시 터널로 전송합니다. (ko)
// Use the accumulated metadata/body to perform the local HTTP request and
// send the response back over the tunnel. (en)
bodyCopy := append([]byte(nil), st.body.Bytes()...)
handleStream(st.open, bodyCopy)
case *protocolpb.Envelope_StreamAck:
payloadType = "stream_ack"
// 현재 gRPC 터널에서는 StreamAck 를 사용하지 않습니다. (ko)
// StreamAck is currently unused for gRPC tunnels. (en)
default:
payloadType = fmt.Sprintf("unknown(%T)", in.Payload)
}
log.Info("received envelope on grpc tunnel client", logging.Fields{
"payload_type": payloadType,
})
}
}
func main() { func main() {
logger := logging.NewStdJSONLogger("client") logger := logging.NewStdJSONLogger("client")
@@ -87,7 +731,7 @@ func main() {
}) })
// CLI 인자 정의 (env 보다 우선 적용됨) // CLI 인자 정의 (env 보다 우선 적용됨)
serverAddrFlag := flag.String("server-addr", "", "DTLS server address (host:port)") serverAddrFlag := flag.String("server-addr", "", "HopGate server address (host:port)")
domainFlag := flag.String("domain", "", "registered domain (e.g. api.example.com)") domainFlag := flag.String("domain", "", "registered domain (e.g. api.example.com)")
apiKeyFlag := flag.String("api-key", "", "client API key for the domain (64 chars)") apiKeyFlag := flag.String("api-key", "", "client API key for the domain (64 chars)")
localTargetFlag := flag.String("local-target", "", "local HTTP target (host:port), e.g. 127.0.0.1:8080") localTargetFlag := flag.String("local-target", "", "local HTTP target (host:port), e.g. 127.0.0.1:8080")
@@ -136,78 +780,16 @@ func main() {
"debug": finalCfg.Debug, "debug": finalCfg.Debug,
}) })
// 4. DTLS 클라이언트 연결 및 핸드셰이크
ctx := context.Background() ctx := context.Background()
// 디버그 모드에서는 서버 인증서 검증을 스킵(InsecureSkipVerify=true) 하여 // 현재 클라이언트는 DTLS 레이어 없이 gRPC 터널만을 사용합니다. (ko)
// self-signed 테스트 인증서도 신뢰하도록 합니다. // The client now uses only the gRPC tunnel, without any DTLS layer. (en)
// 운영 환경에서는 Debug=false 로 두고, 올바른 RootCAs / ServerName 을 갖는 tls.Config 를 사용해야 합니다. if err := runGRPCTunnelClient(ctx, logger, finalCfg); err != nil {
var tlsCfg *tls.Config logger.Error("grpc tunnel client exited with error", logging.Fields{
if finalCfg.Debug {
tlsCfg = &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
} else {
// 운영 모드: 시스템 루트 CA + SNI(ServerName)에 서버 도메인 설정
rootCAs, err := x509.SystemCertPool()
if err != nil || rootCAs == nil {
rootCAs = x509.NewCertPool()
}
tlsCfg = &tls.Config{
RootCAs: rootCAs,
MinVersion: tls.VersionTLS12,
}
}
// DTLS 서버 측은 SNI(ServerName)가 HOP_SERVER_DOMAIN(cfg.Domain)과 일치하는지 검사하므로,
// 클라이언트 TLS 설정에도 반드시 도메인을 설정해준다.
//
// finalCfg.ServerAddr 가 "host:port" 형태이므로, SNI 에는 DNS(host) 부분만 넣어야 한다.
host := finalCfg.ServerAddr
if h, _, err := net.SplitHostPort(finalCfg.ServerAddr); err == nil && strings.TrimSpace(h) != "" {
host = h
}
tlsCfg.ServerName = host
client := dtls.NewPionClient(dtls.PionClientConfig{
Addr: finalCfg.ServerAddr,
TLSConfig: tlsCfg,
})
sess, err := client.Connect()
if err != nil {
logger.Error("failed to establish dtls session", logging.Fields{
"error": err.Error(),
})
os.Exit(1)
}
defer sess.Close()
hsRes, err := dtls.PerformClientHandshake(ctx, sess, logger, finalCfg.Domain, finalCfg.ClientAPIKey, finalCfg.LocalTarget)
if err != nil {
logger.Error("dtls handshake failed", logging.Fields{
"error": err.Error(), "error": err.Error(),
}) })
os.Exit(1) os.Exit(1)
} }
logger.Info("dtls handshake completed", logging.Fields{ logger.Info("grpc tunnel client exited normally", nil)
"domain": hsRes.Domain,
"local_target": finalCfg.LocalTarget,
})
// 5. DTLS 세션 위에서 서버 요청을 처리하는 클라이언트 프록시 루프 시작
clientProxy := proxy.NewClientProxy(logger, finalCfg.LocalTarget)
logger.Info("starting client proxy loop", logging.Fields{
"local_target": finalCfg.LocalTarget,
})
if err := clientProxy.StartLoop(ctx, sess); err != nil {
logger.Error("client proxy loop exited with error", logging.Fields{
"error": err.Error(),
})
os.Exit(1)
}
logger.Info("client proxy loop exited normally", nil)
} }

View File

@@ -19,6 +19,10 @@ import (
"time" "time"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"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"
@@ -28,6 +32,7 @@ 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/protocol" "github.com/dalbodeule/hop-gate/internal/protocol"
protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb"
"github.com/dalbodeule/hop-gate/internal/store" "github.com/dalbodeule/hop-gate/internal/store"
) )
@@ -790,11 +795,441 @@ func firstHeaderValue(hdr map[string][]string, key, def string) string {
return def return def
} }
// firstHeaderValueFromPB 는 map[string]*HeaderValues 형태의 헤더에서 첫 번째 값을 반환하고,
// 값이 없으면 기본값을 반환합니다. (ko)
// firstHeaderValueFromPB returns the first value for a header key in
// map[string]*protocolpb.HeaderValues, or the provided default if the key is
// missing or empty. (en)
func firstHeaderValueFromPB(hdr map[string]*protocolpb.HeaderValues, key, def string) string {
if hdr == nil {
return def
}
if hv, ok := hdr[key]; ok && hv != nil && len(hv.Values) > 0 {
return hv.Values[0]
}
return def
}
// newGRPCTunnelSession 는 단일 OpenTunnel bi-di 스트림에 대한 gRPC 터널 세션을 생성합니다. (ko)
// newGRPCTunnelSession constructs a grpcTunnelSession for a single OpenTunnel
// bi-directional stream. (en)
func newGRPCTunnelSession(stream protocolpb.HopGateTunnel_OpenTunnelServer, logger logging.Logger) *grpcTunnelSession {
if logger == nil {
logger = logging.NewStdJSONLogger("grpc_tunnel_session")
}
return &grpcTunnelSession{
stream: stream,
logger: logger,
pending: make(map[string]*grpcPendingRequest),
readerDone: make(chan struct{}),
}
}
func (t *grpcTunnelSession) send(env *protocolpb.Envelope) error {
t.sendMu.Lock()
defer t.sendMu.Unlock()
return t.stream.Send(env)
}
func (t *grpcTunnelSession) nextHTTPStreamID() string {
t.mu.Lock()
id := t.nextStreamID
t.nextStreamID++
t.mu.Unlock()
return fmt.Sprintf("http-%d", id)
}
// recvLoop 는 OpenTunnel gRPC 스트림에서 Envelope 를 지속적으로 읽어
// HTTP 요청별 pending 테이블로 전달합니다. (ko)
// recvLoop continuously reads Envelope messages from the OpenTunnel gRPC stream
// and dispatches them to per-request pending tables. (en)
func (t *grpcTunnelSession) recvLoop() error {
defer close(t.readerDone)
for {
env, err := t.stream.Recv()
if err != nil {
if err == io.EOF {
t.logger.Info("grpc tunnel session closed by client", nil)
return nil
}
t.logger.Error("grpc tunnel receive error", logging.Fields{
"error": err.Error(),
})
return err
}
var streamID string
switch payload := env.Payload.(type) {
case *protocolpb.Envelope_StreamOpen:
if payload.StreamOpen != nil {
streamID = payload.StreamOpen.Id
}
case *protocolpb.Envelope_StreamData:
if payload.StreamData != nil {
streamID = payload.StreamData.Id
}
case *protocolpb.Envelope_StreamClose:
if payload.StreamClose != nil {
streamID = payload.StreamClose.Id
}
case *protocolpb.Envelope_StreamAck:
// StreamAck 는 gRPC 터널에서는 사용하지 않습니다. HTTP/2 가 신뢰성/순서를 보장합니다. (ko)
// StreamAck is currently unused for gRPC tunnels; HTTP/2 already
// guarantees reliable, ordered delivery. (en)
continue
default:
t.logger.Warn("received unsupported envelope payload on grpc tunnel session", logging.Fields{
"payload_type": fmt.Sprintf("%T", env.Payload),
})
continue
}
if streamID == "" {
t.logger.Warn("received envelope with empty stream id on grpc tunnel session", logging.Fields{})
continue
}
t.mu.Lock()
pending := t.pending[streamID]
t.mu.Unlock()
if pending == nil {
t.logger.Warn("received envelope for unknown stream id on grpc tunnel session", logging.Fields{
"stream_id": streamID,
})
continue
}
select {
case pending.respCh <- env:
case <-pending.doneCh:
t.logger.Warn("pending grpc tunnel request already closed", logging.Fields{
"stream_id": streamID,
})
default:
t.logger.Warn("grpc tunnel response channel buffer full, dropping frame", logging.Fields{
"stream_id": streamID,
})
}
}
}
// ForwardHTTP 는 HTTP 요청을 gRPC 터널 위의 StreamOpen/StreamData/StreamClose 프레임으로 전송하고,
// 역방향 스트림 응답을 수신해 protocol.Response 로 반환합니다. (ko)
// ForwardHTTP forwards an HTTP request over the gRPC tunnel using
// StreamOpen/StreamData/StreamClose frames and reconstructs the reverse
// stream into a protocol.Response. (en)
func (t *grpcTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) {
if ctx == nil {
ctx = context.Background()
}
// Generate a unique stream ID for this HTTP request.
streamID := t.nextHTTPStreamID()
// Channel buffer size for response frames to avoid blocking recvLoop.
const responseChannelBuffer = 16
pending := &grpcPendingRequest{
streamID: streamID,
respCh: make(chan *protocolpb.Envelope, responseChannelBuffer),
doneCh: make(chan struct{}),
}
t.mu.Lock()
if t.pending == nil {
t.pending = make(map[string]*grpcPendingRequest)
}
t.pending[streamID] = pending
t.mu.Unlock()
// Ensure cleanup on exit.
defer func() {
t.mu.Lock()
delete(t.pending, streamID)
t.mu.Unlock()
close(pending.doneCh)
}()
log := logger.With(logging.Fields{
"component": "http_to_tunnel",
"request_id": streamID,
"method": req.Method,
"url": req.URL.String(),
})
log.Info("forwarding http request over grpc tunnel", logging.Fields{
"host": req.Host,
"scheme": req.URL.Scheme,
})
// Build request headers and pseudo-headers.
hdr := make(map[string]*protocolpb.HeaderValues, len(req.Header)+3)
addHeaderValues := func(key string, values []string) {
if len(values) == 0 {
return
}
hv, ok := hdr[key]
if !ok || hv == nil {
hv = &protocolpb.HeaderValues{}
hdr[key] = hv
}
hv.Values = append(hv.Values, values...)
}
for k, vs := range req.Header {
addHeaderValues(k, vs)
}
addHeaderValues(protocol.HeaderKeyMethod, []string{req.Method})
if req.URL != nil {
addHeaderValues(protocol.HeaderKeyURL, []string{req.URL.String()})
}
host := req.Host
if host == "" && req.URL != nil {
host = req.URL.Host
}
if host != "" {
addHeaderValues(protocol.HeaderKeyHost, []string{host})
}
// Send StreamOpen specifying the logical service and headers.
open := &protocolpb.StreamOpen{
Id: streamID,
ServiceName: serviceName,
TargetAddr: "",
Header: hdr,
}
openEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{StreamOpen: open},
}
if err := t.send(openEnv); err != nil {
log.Error("failed to send stream_open on grpc tunnel", logging.Fields{
"error": err.Error(),
})
return nil, err
}
// Send request body as StreamData frames.
var seq uint64
if req.Body != nil {
buf := make([]byte, protocol.StreamChunkSize)
for {
n, err := req.Body.Read(buf)
if n > 0 {
dataCopy := append([]byte(nil), buf[:n]...)
dataEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: &protocolpb.StreamData{
Id: streamID,
Seq: seq,
Data: dataCopy,
},
},
}
if err2 := t.send(dataEnv); err2 != nil {
log.Error("failed to send stream_data on grpc tunnel", logging.Fields{
"error": err2.Error(),
})
return nil, err2
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read http request body for streaming: %w", err)
}
}
}
// Send StreamClose to mark the end of the request body.
closeEnv := &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: &protocolpb.StreamClose{
Id: streamID,
Error: "",
},
},
}
if err := t.send(closeEnv); err != nil {
log.Error("failed to send request stream_close on grpc tunnel", logging.Fields{
"error": err.Error(),
})
return nil, err
}
// Receive reverse stream response (StreamOpen + StreamData* + StreamClose).
var (
resp protocol.Response
bodyBuf bytes.Buffer
gotOpen bool
statusCode = http.StatusOK
)
resp.RequestID = streamID
resp.Header = make(map[string][]string)
for {
select {
case <-ctx.Done():
log.Error("context cancelled while waiting for response", logging.Fields{
"error": ctx.Err().Error(),
})
return nil, ctx.Err()
case <-t.readerDone:
log.Error("grpc tunnel closed while waiting for response", nil)
return nil, fmt.Errorf("grpc tunnel closed")
case env, ok := <-pending.respCh:
if !ok {
log.Error("grpc tunnel response channel closed unexpectedly", nil)
return nil, fmt.Errorf("grpc tunnel response channel closed")
}
switch payload := env.Payload.(type) {
case *protocolpb.Envelope_StreamOpen:
so := payload.StreamOpen
if so == nil {
return nil, fmt.Errorf("stream_open response payload is nil")
}
statusStr := firstHeaderValueFromPB(so.Header, protocol.HeaderKeyStatus, strconv.Itoa(http.StatusOK))
if sc, err := strconv.Atoi(statusStr); err == nil && sc > 0 {
statusCode = sc
}
for k, hv := range so.Header {
if k == protocol.HeaderKeyMethod ||
k == protocol.HeaderKeyURL ||
k == protocol.HeaderKeyHost ||
k == protocol.HeaderKeyStatus {
continue
}
if hv == nil || len(hv.Values) == 0 {
continue
}
resp.Header[k] = append([]string(nil), hv.Values...)
}
gotOpen = true
case *protocolpb.Envelope_StreamData:
sd := payload.StreamData
if sd == nil {
return nil, fmt.Errorf("stream_data response payload is nil")
}
if len(sd.Data) > 0 {
if _, err := bodyBuf.Write(sd.Data); err != nil {
return nil, fmt.Errorf("buffer stream_data response: %w", err)
}
}
case *protocolpb.Envelope_StreamClose:
sc := payload.StreamClose
if sc == nil {
return nil, fmt.Errorf("stream_close response payload is nil")
}
// Complete the protocol.Response using collected headers/body. (en)
resp.Status = statusCode
resp.Body = bodyBuf.Bytes()
resp.Error = sc.Error
log.Info("received stream http response over grpc tunnel", logging.Fields{
"status": resp.Status,
"error": resp.Error,
})
if !gotOpen {
return nil, fmt.Errorf("received stream_close without prior stream_open for stream %q", streamID)
}
return &resp, nil
default:
return nil, fmt.Errorf("unexpected envelope payload type %T in stream response", env.Payload)
}
}
}
}
var ( var (
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
sessionsByDomain = make(map[string]*dtlsSessionWrapper) sessionsByDomain = make(map[string]*dtlsSessionWrapper)
) )
// grpcPendingRequest tracks a single HTTP request waiting for its response on a gRPC tunnel. (en)
type grpcPendingRequest struct {
streamID string
respCh chan *protocolpb.Envelope
doneCh chan struct{}
}
// grpcTunnelSession represents a single long-lived gRPC tunnel (OpenTunnel stream)
// that can multiplex multiple HTTP requests by StreamID. (en)
type grpcTunnelSession struct {
stream protocolpb.HopGateTunnel_OpenTunnelServer
logger logging.Logger
mu sync.Mutex
nextStreamID uint64
pending map[string]*grpcPendingRequest
readerDone chan struct{}
sendMu sync.Mutex
}
var (
tunnelsMu sync.RWMutex
tunnelsByDomain = make(map[string]*grpcTunnelSession)
)
func registerTunnelForDomain(domain string, sess *grpcTunnelSession, logger logging.Logger) string {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" || sess == nil {
return ""
}
tunnelsMu.Lock()
tunnelsByDomain[d] = sess
tunnelsMu.Unlock()
logger.Info("registered grpc tunnel for domain", logging.Fields{
"domain": d,
})
return d
}
func unregisterTunnelForDomain(domain string, sess *grpcTunnelSession, logger logging.Logger) {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" || sess == nil {
return
}
tunnelsMu.Lock()
cur := tunnelsByDomain[d]
if cur == sess {
delete(tunnelsByDomain, d)
}
tunnelsMu.Unlock()
logger.Info("unregistered grpc tunnel for domain", logging.Fields{
"domain": d,
})
}
func getTunnelForHost(host string) *grpcTunnelSession {
h := host
if i := strings.Index(h, ":"); i != -1 {
h = h[:i]
}
h = strings.ToLower(strings.TrimSpace(h))
if h == "" {
return nil
}
tunnelsMu.RLock()
defer tunnelsMu.RUnlock()
return tunnelsByDomain[h]
}
// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다.
// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다.
// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다. // statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다.
// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다. // Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다.
type statusRecorder struct { type statusRecorder struct {
@@ -807,6 +1242,118 @@ func (w *statusRecorder) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code) w.ResponseWriter.WriteHeader(code)
} }
// grpcTunnelServer 는 HopGate gRPC 터널 서비스(HopGateTunnel)의 서버 구현체입니다. (ko)
// grpcTunnelServer implements the HopGateTunnel gRPC service on the server side. (en)
type grpcTunnelServer struct {
protocolpb.UnimplementedHopGateTunnelServer
logger logging.Logger
validator dtls.DomainValidator
}
// newGRPCTunnelServer 는 gRPC 터널 서버 구현체를 생성합니다. (ko)
// newGRPCTunnelServer constructs a new gRPC tunnel server implementation. (en)
func newGRPCTunnelServer(logger logging.Logger, validator dtls.DomainValidator) *grpcTunnelServer {
baseLogger := logger
if baseLogger == nil {
baseLogger = logging.NewStdJSONLogger("grpc_tunnel")
}
return &grpcTunnelServer{
logger: baseLogger.With(logging.Fields{
"component": "grpc_tunnel",
}),
validator: validator,
}
}
// OpenTunnel 은 클라이언트와 서버 간 장기 유지 bi-directional gRPC 스트림을 처리합니다. (ko)
// OpenTunnel handles the long-lived bi-directional gRPC stream between the
// server and a HopGate client. It performs an initial control-stream
// handshake (domain/API key validation), registers the tunnel for the
// authenticated domain, and runs a central receive loop for HTTP streams. (en)
func (s *grpcTunnelServer) OpenTunnel(stream protocolpb.HopGateTunnel_OpenTunnelServer) error {
ctx := stream.Context()
// 원격 주소가 있으면 로그 필드에 추가합니다. (ko)
// Attach remote address from the peer info to log fields when available. (en)
fields := logging.Fields{}
if p, ok := peer.FromContext(ctx); ok && p.Addr != nil {
fields["remote_addr"] = p.Addr.String()
}
log := s.logger.With(fields)
log.Info("grpc tunnel opened", nil)
defer log.Info("grpc tunnel closed", nil)
// 1) 초기 control StreamOpen(id="control-0") 을 수신하여 핸드셰이크를 수행합니다. (ko)
// 1) Receive initial control StreamOpen (id="control-0") and perform handshake. (en)
env, err := stream.Recv()
if err != nil {
if err == io.EOF {
log.Warn("grpc tunnel closed before sending control stream_open", nil)
return status.Error(codes.InvalidArgument, "missing initial control stream_open")
}
log.Error("failed to receive initial control stream_open", logging.Fields{
"error": err.Error(),
})
return err
}
soPayload, ok := env.Payload.(*protocolpb.Envelope_StreamOpen)
if !ok || soPayload.StreamOpen == nil {
log.Error("first envelope on grpc tunnel is not stream_open", logging.Fields{
"payload_type": fmt.Sprintf("%T", env.Payload),
})
return status.Error(codes.InvalidArgument, "first envelope on tunnel must be control stream_open")
}
control := soPayload.StreamOpen
controlID := strings.TrimSpace(control.Id)
headers := control.Header
domain := firstHeaderValueFromPB(headers, "X-HopGate-Domain", "")
apiKey := firstHeaderValueFromPB(headers, "X-HopGate-API-Key", "")
localTarget := firstHeaderValueFromPB(headers, "X-HopGate-Local-Target", "")
if domain == "" || apiKey == "" {
log.Warn("grpc tunnel control stream missing domain or api key", logging.Fields{
"control_id": controlID,
})
return status.Error(codes.Unauthenticated, "missing domain or api key on control stream_open")
}
// Validate (domain, api_key) using the shared domain validator.
if s.validator != nil {
if err := s.validator.ValidateDomainAPIKey(ctx, domain, apiKey); err != nil {
log.Warn("grpc tunnel domain/api_key validation failed", logging.Fields{
"domain": domain,
"error": err.Error(),
})
return status.Error(codes.PermissionDenied, "invalid domain or api key")
}
}
log.Info("grpc tunnel handshake succeeded", logging.Fields{
"domain": domain,
"local_target": localTarget,
"control_id": controlID,
})
// Register this tunnel session for the authenticated domain.
sessionLogger := s.logger.With(logging.Fields{
"domain": domain,
})
tunnel := newGRPCTunnelSession(stream, sessionLogger)
normalizedDomain := registerTunnelForDomain(domain, tunnel, s.logger)
defer unregisterTunnelForDomain(normalizedDomain, tunnel, s.logger)
// 2) 이후 수신되는 StreamOpen/StreamData/StreamClose 는 grpcTunnelSession.recvLoop 에서
// HTTP 요청별로 demux 됩니다. (ko)
// 2) Subsequent StreamOpen/StreamData/StreamClose frames are demultiplexed per
// HTTP request by grpcTunnelSession.recvLoop. (en)
return tunnel.recvLoop()
}
// hopGateOwnedHeaders 는 HopGate 서버가 스스로 관리하는 응답 헤더 목록입니다. (ko) // hopGateOwnedHeaders 는 HopGate 서버가 스스로 관리하는 응답 헤더 목록입니다. (ko)
// hopGateOwnedHeaders lists response headers that are owned by the HopGate server. (en) // hopGateOwnedHeaders lists response headers that are owned by the HopGate server. (en)
var hopGateOwnedHeaders = map[string]struct{}{ var hopGateOwnedHeaders = map[string]struct{}{
@@ -887,6 +1434,23 @@ func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Ha
}) })
} }
// grpcOrHTTPHandler 는 단일 HTTPS 포트에서 gRPC(OpenTunnel)와 일반 HTTP 요청을
// Content-Type 및 프로토콜(HTTP/2) 기준으로 라우팅하는 헬퍼입니다. (ko)
// grpcOrHTTPHandler routes between gRPC (OpenTunnel) and regular HTTP handlers
// on a single HTTPS port, based on Content-Type and protocol (HTTP/2). (en)
func grpcOrHTTPHandler(grpcServer *grpc.Server, httpHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// gRPC 요청은 HTTP/2 + Content-Type: application/grpc 조합으로 들어옵니다. (ko)
// gRPC requests arrive as HTTP/2 with Content-Type: application/grpc. (en)
if r.ProtoMajor == 2 && strings.Contains(r.Header.Get("Content-Type"), "application/grpc") {
grpcServer.ServeHTTP(w, r)
return
}
httpHandler.ServeHTTP(w, r)
})
}
func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) { func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) {
d := strings.ToLower(strings.TrimSpace(domain)) d := strings.ToLower(strings.TrimSpace(domain))
if d == "" { if d == "" {
@@ -1031,8 +1595,10 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
return return
} }
// 2. 일반 HTTP 요청은 DTLS 를 통해 클라이언트로 포워딩 // 2. 일반 HTTP 요청은 활성 gRPC 터널을 통해 클라이언트로 포워딩합니다. (ko)
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. // 2. Regular HTTP requests are forwarded to clients over active gRPC tunnels. (en)
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. (ko)
// For now, use a fixed logical service name "web"; this can be improved with a Router later. (en)
serviceName := "web" serviceName := "web"
// Host 헤더에서 포트를 제거하고 소문자로 정규화합니다. // Host 헤더에서 포트를 제거하고 소문자로 정규화합니다.
@@ -1055,14 +1621,14 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
return return
} }
sessWrapper := getSessionForHost(hostLower) tunnel := getTunnelForHost(hostLower)
if sessWrapper == nil { if tunnel == nil {
log.Warn("no dtls session for host", logging.Fields{ log.Warn("no tunnel for host", logging.Fields{
"host": r.Host, "host": r.Host,
}) })
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc() observability.ProxyErrorsTotal.WithLabelValues("no_tunnel_session").Inc()
// 등록되지 않았거나 활성 세션이 없는 도메인으로의 요청은 404 로 응답합니다. (ko) // 등록되지 않았거나 활성 터널이 없는 도메인으로의 요청은 404 로 응답합니다. (ko)
// Requests for hosts without an active DTLS session return 404. (en) // Requests for hosts without an active tunnel return 404. (en)
writeErrorPage(sr, r, http.StatusNotFound) writeErrorPage(sr, r, http.StatusNotFound)
return return
} }
@@ -1090,14 +1656,15 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
} }
} }
// r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 // r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 (ko)
// r.Body is consumed inside ForwardHTTP; ensure it is closed here. (en)
defer r.Body.Close() defer r.Body.Close()
// 서버 측에서 DTLS → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해 // 서버 측에서 gRPC 터널 → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해
// 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며, // 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며,
// HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko) // HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko)
// Apply an overall timeout (default 15s, configurable via // Apply an overall timeout (default 15s, configurable via
// HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the DTLS 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 {
@@ -1118,7 +1685,7 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
// Context cancelled, do not proceed. // Context cancelled, do not proceed.
return return
default: default:
resp, err := sessWrapper.ForwardHTTP(ctx, logger, r, serviceName) resp, err := tunnel.ForwardHTTP(ctx, logger, r, serviceName)
resultCh <- forwardResult{resp: resp, err: err} resultCh <- forwardResult{resp: resp, err: err}
} }
}() }()
@@ -1127,20 +1694,20 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Error("forward over dtls timed out", logging.Fields{ log.Error("forward over tunnel timed out", logging.Fields{
"timeout_seconds": int64(proxyTimeout.Seconds()), "timeout_seconds": int64(proxyTimeout.Seconds()),
"error": ctx.Err().Error(), "error": ctx.Err().Error(),
}) })
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_timeout").Inc() observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc()
writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
return return
case res := <-resultCh: case res := <-resultCh:
if res.err != nil { if res.err != nil {
log.Error("forward over dtls failed", logging.Fields{ log.Error("forward over tunnel failed", logging.Fields{
"error": res.err.Error(), "error": res.err.Error(),
}) })
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_failed").Inc() observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc()
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
return return
} }
@@ -1257,6 +1824,10 @@ func main() {
}) })
} }
// gRPC 터널 핸드셰이크에서 사용할 도메인 검증기 구성. (ko)
// Construct domain validator to be used by the gRPC tunnel handshake. (en)
domainValidator := admin.NewEntDomainValidator(logger, dbClient)
// 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되 // 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되
// ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다. // ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다.
// 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed // 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed
@@ -1370,23 +1941,6 @@ func main() {
} }
} }
// 4. DTLS 서버 리스너 생성 (pion/dtls 기반)
dtlsServer, err := dtls.NewPionServer(dtls.PionServerConfig{
Addr: cfg.DTLSListen,
TLSConfig: dtlsTLSConfig,
})
if err != nil {
logger.Error("failed to start dtls server", logging.Fields{
"error": err.Error(),
})
os.Exit(1)
}
defer dtlsServer.Close()
logger.Info("dtls server listening", logging.Fields{
"addr": cfg.DTLSListen,
})
// 5. HTTP / HTTPS 서버 시작 // 5. HTTP / HTTPS 서버 시작
// 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며, // 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며,
// 기본값은 15초입니다. (ko) // 기본값은 15초입니다. (ko)
@@ -1464,6 +2018,11 @@ func main() {
// 기본 HTTP → DTLS Proxy 엔트리 포인트 // 기본 HTTP → DTLS Proxy 엔트리 포인트
httpMux.Handle("/", httpHandler) httpMux.Handle("/", httpHandler)
// gRPC server for client tunnels (OpenTunnel). (en)
// 클라이언트 터널(OpenTunnel)을 처리하는 gRPC 서버 인스턴스를 생성합니다. (ko)
grpcSrv := grpc.NewServer()
protocolpb.RegisterHopGateTunnelServer(grpcSrv, newGRPCTunnelServer(logger, domainValidator))
// HTTP: 평문 포트 // HTTP: 평문 포트
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: cfg.HTTPListen, Addr: cfg.HTTPListen,
@@ -1481,9 +2040,15 @@ func main() {
}() }()
// HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능) // HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능)
// gRPC(OpenTunnel)을 위해 HTTP/2(h2)가 활성화되어 있어야 합니다. (ko)
// HTTP/2 (h2) must be enabled for gRPC (OpenTunnel) over TLS. (en)
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: grpcOrHTTPHandler(grpcSrv, httpMux),
TLSConfig: acmeTLSCfg, TLSConfig: acmeTLSCfg,
} }
go func() { go func() {
@@ -1497,89 +2062,7 @@ func main() {
} }
}() }()
// 6. 도메인 검증기 준비 (ent + PostgreSQL 기반 실제 구현) // DTLS 레이어 제거 이후에는 gRPC 및 HTTP/HTTPS 서버 goroutine 만 유지합니다. (ko)
// Admin Plane 에서 관리하는 Domain 테이블을 사용해 (domain, client_api_key) 조합을 검증합니다. // After removing the DTLS layer, only the gRPC and HTTP/HTTPS servers are kept running. (en)
domainValidator := admin.NewEntDomainValidator(logger, dbClient) select {}
// DTLS 핸드셰이크 단계에서는 클라이언트가 제시한 도메인의 DNS(A/AAAA)가
// HOP_ACME_EXPECT_IPS 에 설정된 IP들 중 하나 이상을 가리키는지 추가로 검증합니다. (ko)
// During DTLS handshake, additionally verify that the presented domain resolves
// (via A/AAAA) to at least one IP configured in HOP_ACME_EXPECT_IPS. (en)
// EXPECT_IPS 가 비어 있으면 DNS 기반 검증은 생략하고 DB 검증만 수행합니다. (ko)
// If EXPECT_IPS is empty, only DB-based validation is performed. (en)
expectedHandshakeIPs := parseExpectedIPsFromEnv(logger, "HOP_ACME_EXPECT_IPS")
var validator dtls.DomainValidator = &domainGateValidator{
expectedIPs: expectedHandshakeIPs,
inner: domainValidator,
logger: logger,
}
// 7. DTLS Accept 루프 + Handshake
for {
sess, err := dtlsServer.Accept()
if err != nil {
logger.Error("dtls accept failed", logging.Fields{
"error": err.Error(),
})
continue
}
// 각 세션별로 goroutine 에서 핸드셰이크 및 후속 처리를 수행합니다.
go func(s dtls.Session) {
// NOTE: 세션은 HTTP↔DTLS 터널링에 계속 사용해야 하므로 이곳에서 Close 하지 않습니다.
// 세션 종료/타임아웃 관리는 별도의 세션 매니저(TODO)에서 담당해야 합니다.
hsRes, err := dtls.PerformServerHandshake(ctx, s, validator, logger)
if err != nil {
// 핸드셰이크 실패 메트릭 기록
observability.DTLSHandshakesTotal.WithLabelValues("failure").Inc()
// PerformServerHandshake 내부에서 이미 상세 로그를 남기므로 여기서는 요약만 기록합니다.
logger.Warn("dtls handshake failed", logging.Fields{
"session_id": s.ID(),
"error": err.Error(),
})
// 핸드셰이크 실패 시 세션을 명시적으로 종료하여 invalid SNI 등 오류에서
// 연결이 열린 채로 남지 않도록 합니다.
_ = s.Close()
return
}
// Handshake 성공 메트릭 기록
observability.DTLSHandshakesTotal.WithLabelValues("success").Inc()
// Handshake 성공: 서버 측은 어떤 도메인이 연결되었는지 알 수 있습니다.
logger.Info("dtls handshake completed", logging.Fields{
"session_id": s.ID(),
"domain": hsRes.Domain,
})
// Handshake 가 완료된 세션을 도메인에 매핑해 HTTP 요청 시 사용할 수 있도록 등록합니다.
registerSessionForDomain(hsRes.Domain, s, logger)
// Handshake 가 정상적으로 끝난 이후, 실제로 해당 도메인에 대해 ACME 인증서를 확보/연장합니다.
// Debug 모드에서도 ACME 는 항상 시도하지만, 위에서 HOP_ACME_USE_STAGING=true 로 설정되어
// Staging CA 를 사용하게 됩니다.
if hsRes.Domain != "" {
go func(domain string) {
acmeLogger := logger.With(logging.Fields{
"component": "acme_post_handshake",
"domain": domain,
"debug": cfg.Debug,
})
if _, err := acme.NewLegoManagerFromEnv(context.Background(), acmeLogger, []string{domain}); err != nil {
acmeLogger.Error("failed to ensure acme certificate after dtls handshake", logging.Fields{
"error": err.Error(),
})
return
}
acmeLogger.Info("acme certificate ensured after dtls handshake", nil)
}(hsRes.Domain)
}
// TODO:
// - hsRes.Domain 과 연결된 세션을 proxy 레이어에 등록
// - HTTP 요청을 이 세션을 통해 해당 클라이언트로 라우팅
// - 세션 생명주기/타임아웃 관리 등
}(sess)
}
} }

View File

@@ -27,7 +27,6 @@ services:
# 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준) # 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준)
- "80:80" # HTTP - "80:80" # HTTP
- "443:443" # HTTPS (TCP) - "443:443" # HTTPS (TCP)
- "443:443/udp" # DTLS (UDP)
volumes: volumes:
# ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관) # ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관)

View File

@@ -1,799 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.1
// source: internal/protocol/hopgate_stream.proto
package protocolpb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다.
// HeaderValues wraps multiple header values for a single HTTP header key.
type HeaderValues struct {
state protoimpl.MessageState `protogen:"open.v1"`
Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeaderValues) Reset() {
*x = HeaderValues{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeaderValues) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeaderValues) ProtoMessage() {}
func (x *HeaderValues) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead.
func (*HeaderValues) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{0}
}
func (x *HeaderValues) GetValues() []string {
if x != nil {
return x.Values
}
return nil
}
// Request 는 DTLS 터널 위에서 교환되는 HTTP 요청을 표현합니다.
// This mirrors internal/protocol.Request.
type Request struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` // optional client identifier
ServiceName string `protobuf:"bytes,3,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // logical service name on the client side
Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"`
Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"`
// HTTP header: map of key -> multiple values.
Header map[string]*HeaderValues `protobuf:"bytes,6,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Raw HTTP body bytes.
Body []byte `protobuf:"bytes,7,opt,name=body,proto3" json:"body,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Request) Reset() {
*x = Request{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Request) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Request) ProtoMessage() {}
func (x *Request) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Request.ProtoReflect.Descriptor instead.
func (*Request) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{1}
}
func (x *Request) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *Request) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *Request) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *Request) GetMethod() string {
if x != nil {
return x.Method
}
return ""
}
func (x *Request) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *Request) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
func (x *Request) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
// Response 는 DTLS 터널 위에서 교환되는 HTTP 응답을 표현합니다.
// This mirrors internal/protocol.Response.
type Response struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
Status int32 `protobuf:"varint,2,opt,name=status,proto3" json:"status,omitempty"`
// HTTP header.
Header map[string]*HeaderValues `protobuf:"bytes,3,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Raw HTTP body bytes.
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"`
// Optional error description when tunneling fails.
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Response) Reset() {
*x = Response{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Response) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Response) ProtoMessage() {}
func (x *Response) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Response.ProtoReflect.Descriptor instead.
func (*Response) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{2}
}
func (x *Response) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *Response) GetStatus() int32 {
if x != nil {
return x.Status
}
return 0
}
func (x *Response) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
func (x *Response) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
func (x *Response) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// StreamOpen 은 새로운 스트림(HTTP 요청/응답, WebSocket 등)을 여는 메시지입니다.
// This represents opening a new stream (HTTP request/response, WebSocket, etc.).
type StreamOpen struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID (text form)
// Which logical service / local target to use on the client side.
ServiceName string `protobuf:"bytes,2,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
TargetAddr string `protobuf:"bytes,3,opt,name=target_addr,json=targetAddr,proto3" json:"target_addr,omitempty"` // e.g. "127.0.0.1:8080"
// Initial HTTP-like headers (including Upgrade, etc.).
Header map[string]*HeaderValues `protobuf:"bytes,4,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamOpen) Reset() {
*x = StreamOpen{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamOpen) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamOpen) ProtoMessage() {}
func (x *StreamOpen) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamOpen.ProtoReflect.Descriptor instead.
func (*StreamOpen) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{3}
}
func (x *StreamOpen) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamOpen) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *StreamOpen) GetTargetAddr() string {
if x != nil {
return x.TargetAddr
}
return ""
}
func (x *StreamOpen) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
// StreamData 는 이미 열린 스트림에 대한 단방향 데이터 프레임입니다.
// This is a unidirectional data frame on an already-open stream.
type StreamData struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID
Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` // per-stream sequence number starting from 0
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamData) Reset() {
*x = StreamData{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamData) ProtoMessage() {}
func (x *StreamData) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamData.ProtoReflect.Descriptor instead.
func (*StreamData) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{4}
}
func (x *StreamData) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamData) GetSeq() uint64 {
if x != nil {
return x.Seq
}
return 0
}
func (x *StreamData) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
// StreamAck 는 StreamData 에 대한 ACK/NACK 및 선택적 재전송 힌트를 전달합니다.
// This conveys ACK/NACK and optional retransmission hints for StreamData.
type StreamAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// Last contiguously received sequence number (starting from 0).
AckSeq uint64 `protobuf:"varint,2,opt,name=ack_seq,json=ackSeq,proto3" json:"ack_seq,omitempty"`
// Additional missing sequence numbers beyond ack_seq (optional).
LostSeqs []uint64 `protobuf:"varint,3,rep,packed,name=lost_seqs,json=lostSeqs,proto3" json:"lost_seqs,omitempty"`
// Optional receive window size hint.
WindowSize uint32 `protobuf:"varint,4,opt,name=window_size,json=windowSize,proto3" json:"window_size,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamAck) Reset() {
*x = StreamAck{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamAck) ProtoMessage() {}
func (x *StreamAck) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamAck.ProtoReflect.Descriptor instead.
func (*StreamAck) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{5}
}
func (x *StreamAck) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamAck) GetAckSeq() uint64 {
if x != nil {
return x.AckSeq
}
return 0
}
func (x *StreamAck) GetLostSeqs() []uint64 {
if x != nil {
return x.LostSeqs
}
return nil
}
func (x *StreamAck) GetWindowSize() uint32 {
if x != nil {
return x.WindowSize
}
return 0
}
// StreamClose 는 스트림 종료(정상/에러)를 알립니다.
// This indicates normal or error termination of a stream.
type StreamClose struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // empty means normal close
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamClose) Reset() {
*x = StreamClose{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamClose) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamClose) ProtoMessage() {}
func (x *StreamClose) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamClose.ProtoReflect.Descriptor instead.
func (*StreamClose) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{6}
}
func (x *StreamClose) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamClose) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다.
// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다.
// Envelope is the top-level container exchanged over the DTLS session.
// Exactly one payload (http_request/http_response/stream_*) is set per message.
type Envelope struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
//
// *Envelope_HttpRequest
// *Envelope_HttpResponse
// *Envelope_StreamOpen
// *Envelope_StreamData
// *Envelope_StreamClose
// *Envelope_StreamAck
Payload isEnvelope_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Envelope) Reset() {
*x = Envelope{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Envelope) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Envelope) ProtoMessage() {}
func (x *Envelope) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Envelope.ProtoReflect.Descriptor instead.
func (*Envelope) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{7}
}
func (x *Envelope) GetPayload() isEnvelope_Payload {
if x != nil {
return x.Payload
}
return nil
}
func (x *Envelope) GetHttpRequest() *Request {
if x != nil {
if x, ok := x.Payload.(*Envelope_HttpRequest); ok {
return x.HttpRequest
}
}
return nil
}
func (x *Envelope) GetHttpResponse() *Response {
if x != nil {
if x, ok := x.Payload.(*Envelope_HttpResponse); ok {
return x.HttpResponse
}
}
return nil
}
func (x *Envelope) GetStreamOpen() *StreamOpen {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamOpen); ok {
return x.StreamOpen
}
}
return nil
}
func (x *Envelope) GetStreamData() *StreamData {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamData); ok {
return x.StreamData
}
}
return nil
}
func (x *Envelope) GetStreamClose() *StreamClose {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamClose); ok {
return x.StreamClose
}
}
return nil
}
func (x *Envelope) GetStreamAck() *StreamAck {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamAck); ok {
return x.StreamAck
}
}
return nil
}
type isEnvelope_Payload interface {
isEnvelope_Payload()
}
type Envelope_HttpRequest struct {
HttpRequest *Request `protobuf:"bytes,1,opt,name=http_request,json=httpRequest,proto3,oneof"`
}
type Envelope_HttpResponse struct {
HttpResponse *Response `protobuf:"bytes,2,opt,name=http_response,json=httpResponse,proto3,oneof"`
}
type Envelope_StreamOpen struct {
StreamOpen *StreamOpen `protobuf:"bytes,3,opt,name=stream_open,json=streamOpen,proto3,oneof"`
}
type Envelope_StreamData struct {
StreamData *StreamData `protobuf:"bytes,4,opt,name=stream_data,json=streamData,proto3,oneof"`
}
type Envelope_StreamClose struct {
StreamClose *StreamClose `protobuf:"bytes,5,opt,name=stream_close,json=streamClose,proto3,oneof"`
}
type Envelope_StreamAck struct {
StreamAck *StreamAck `protobuf:"bytes,6,opt,name=stream_ack,json=streamAck,proto3,oneof"`
}
func (*Envelope_HttpRequest) isEnvelope_Payload() {}
func (*Envelope_HttpResponse) isEnvelope_Payload() {}
func (*Envelope_StreamOpen) isEnvelope_Payload() {}
func (*Envelope_StreamData) isEnvelope_Payload() {}
func (*Envelope_StreamClose) isEnvelope_Payload() {}
func (*Envelope_StreamAck) isEnvelope_Payload() {}
var File_internal_protocol_hopgate_stream_proto protoreflect.FileDescriptor
const file_internal_protocol_hopgate_stream_proto_rawDesc = "" +
"\n" +
"&internal/protocol/hopgate_stream.proto\x12\x13hopgate.protocol.v1\"&\n" +
"\fHeaderValues\x12\x16\n" +
"\x06values\x18\x01 \x03(\tR\x06values\"\xc6\x02\n" +
"\aRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1b\n" +
"\tclient_id\x18\x02 \x01(\tR\bclientId\x12!\n" +
"\fservice_name\x18\x03 \x01(\tR\vserviceName\x12\x16\n" +
"\x06method\x18\x04 \x01(\tR\x06method\x12\x10\n" +
"\x03url\x18\x05 \x01(\tR\x03url\x12@\n" +
"\x06header\x18\x06 \x03(\v2(.hopgate.protocol.v1.Request.HeaderEntryR\x06header\x12\x12\n" +
"\x04body\x18\a \x01(\fR\x04body\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x8c\x02\n" +
"\bResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" +
"\x06status\x18\x02 \x01(\x05R\x06status\x12A\n" +
"\x06header\x18\x03 \x03(\v2).hopgate.protocol.v1.Response.HeaderEntryR\x06header\x12\x12\n" +
"\x04body\x18\x04 \x01(\fR\x04body\x12\x14\n" +
"\x05error\x18\x05 \x01(\tR\x05error\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x83\x02\n" +
"\n" +
"StreamOpen\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12!\n" +
"\fservice_name\x18\x02 \x01(\tR\vserviceName\x12\x1f\n" +
"\vtarget_addr\x18\x03 \x01(\tR\n" +
"targetAddr\x12C\n" +
"\x06header\x18\x04 \x03(\v2+.hopgate.protocol.v1.StreamOpen.HeaderEntryR\x06header\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"B\n" +
"\n" +
"StreamData\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" +
"\x03seq\x18\x02 \x01(\x04R\x03seq\x12\x12\n" +
"\x04data\x18\x03 \x01(\fR\x04data\"r\n" +
"\tStreamAck\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
"\aack_seq\x18\x02 \x01(\x04R\x06ackSeq\x12\x1b\n" +
"\tlost_seqs\x18\x03 \x03(\x04R\blostSeqs\x12\x1f\n" +
"\vwindow_size\x18\x04 \x01(\rR\n" +
"windowSize\"3\n" +
"\vStreamClose\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"\xae\x03\n" +
"\bEnvelope\x12A\n" +
"\fhttp_request\x18\x01 \x01(\v2\x1c.hopgate.protocol.v1.RequestH\x00R\vhttpRequest\x12D\n" +
"\rhttp_response\x18\x02 \x01(\v2\x1d.hopgate.protocol.v1.ResponseH\x00R\fhttpResponse\x12B\n" +
"\vstream_open\x18\x03 \x01(\v2\x1f.hopgate.protocol.v1.StreamOpenH\x00R\n" +
"streamOpen\x12B\n" +
"\vstream_data\x18\x04 \x01(\v2\x1f.hopgate.protocol.v1.StreamDataH\x00R\n" +
"streamData\x12E\n" +
"\fstream_close\x18\x05 \x01(\v2 .hopgate.protocol.v1.StreamCloseH\x00R\vstreamClose\x12?\n" +
"\n" +
"stream_ack\x18\x06 \x01(\v2\x1e.hopgate.protocol.v1.StreamAckH\x00R\tstreamAckB\t\n" +
"\apayloadB@Z>github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpbb\x06proto3"
var (
file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once
file_internal_protocol_hopgate_stream_proto_rawDescData []byte
)
func file_internal_protocol_hopgate_stream_proto_rawDescGZIP() []byte {
file_internal_protocol_hopgate_stream_proto_rawDescOnce.Do(func() {
file_internal_protocol_hopgate_stream_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)))
})
return file_internal_protocol_hopgate_stream_proto_rawDescData
}
var file_internal_protocol_hopgate_stream_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_internal_protocol_hopgate_stream_proto_goTypes = []any{
(*HeaderValues)(nil), // 0: hopgate.protocol.v1.HeaderValues
(*Request)(nil), // 1: hopgate.protocol.v1.Request
(*Response)(nil), // 2: hopgate.protocol.v1.Response
(*StreamOpen)(nil), // 3: hopgate.protocol.v1.StreamOpen
(*StreamData)(nil), // 4: hopgate.protocol.v1.StreamData
(*StreamAck)(nil), // 5: hopgate.protocol.v1.StreamAck
(*StreamClose)(nil), // 6: hopgate.protocol.v1.StreamClose
(*Envelope)(nil), // 7: hopgate.protocol.v1.Envelope
nil, // 8: hopgate.protocol.v1.Request.HeaderEntry
nil, // 9: hopgate.protocol.v1.Response.HeaderEntry
nil, // 10: hopgate.protocol.v1.StreamOpen.HeaderEntry
}
var file_internal_protocol_hopgate_stream_proto_depIdxs = []int32{
8, // 0: hopgate.protocol.v1.Request.header:type_name -> hopgate.protocol.v1.Request.HeaderEntry
9, // 1: hopgate.protocol.v1.Response.header:type_name -> hopgate.protocol.v1.Response.HeaderEntry
10, // 2: hopgate.protocol.v1.StreamOpen.header:type_name -> hopgate.protocol.v1.StreamOpen.HeaderEntry
1, // 3: hopgate.protocol.v1.Envelope.http_request:type_name -> hopgate.protocol.v1.Request
2, // 4: hopgate.protocol.v1.Envelope.http_response:type_name -> hopgate.protocol.v1.Response
3, // 5: hopgate.protocol.v1.Envelope.stream_open:type_name -> hopgate.protocol.v1.StreamOpen
4, // 6: hopgate.protocol.v1.Envelope.stream_data:type_name -> hopgate.protocol.v1.StreamData
6, // 7: hopgate.protocol.v1.Envelope.stream_close:type_name -> hopgate.protocol.v1.StreamClose
5, // 8: hopgate.protocol.v1.Envelope.stream_ack:type_name -> hopgate.protocol.v1.StreamAck
0, // 9: hopgate.protocol.v1.Request.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
0, // 10: hopgate.protocol.v1.Response.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
0, // 11: hopgate.protocol.v1.StreamOpen.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
12, // [12:12] is the sub-list for method output_type
12, // [12:12] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
}
func init() { file_internal_protocol_hopgate_stream_proto_init() }
func file_internal_protocol_hopgate_stream_proto_init() {
if File_internal_protocol_hopgate_stream_proto != nil {
return
}
file_internal_protocol_hopgate_stream_proto_msgTypes[7].OneofWrappers = []any{
(*Envelope_HttpRequest)(nil),
(*Envelope_HttpResponse)(nil),
(*Envelope_StreamOpen)(nil),
(*Envelope_StreamData)(nil),
(*Envelope_StreamClose)(nil),
(*Envelope_StreamAck)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_internal_protocol_hopgate_stream_proto_goTypes,
DependencyIndexes: file_internal_protocol_hopgate_stream_proto_depIdxs,
MessageInfos: file_internal_protocol_hopgate_stream_proto_msgTypes,
}.Build()
File_internal_protocol_hopgate_stream_proto = out.File
file_internal_protocol_hopgate_stream_proto_goTypes = nil
file_internal_protocol_hopgate_stream_proto_depIdxs = nil
}

7
go.mod
View File

@@ -7,9 +7,9 @@ require (
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/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/pion/dtls/v3 v3.0.7
github.com/prometheus/client_golang v1.19.0 github.com/prometheus/client_golang v1.19.0
golang.org/x/net v0.47.0 golang.org/x/net v0.47.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.10
) )
@@ -20,15 +20,13 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-openapi/inflect v0.19.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/miekg/dns v1.1.68 // indirect github.com/miekg/dns v1.1.68 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
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
@@ -41,4 +39,5 @@ require (
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
) )

34
go.sum
View File

@@ -14,18 +14,24 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q= github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q=
github.com/go-acme/lego/v4 v4.28.1/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4= github.com/go-acme/lego/v4 v4.28.1/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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=
@@ -46,12 +52,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/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 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/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=
@@ -72,6 +72,18 @@ 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=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
@@ -86,6 +98,12 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 KiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -4,7 +4,7 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
=== High-level concept === === High-level concept ===
- HopGate is a reverse HTTP gateway. - HopGate is a reverse HTTP gateway.
- A single public server terminates HTTPS and DTLS, and tunnels HTTP traffic to multiple clients. - A single public server terminates HTTPS and exposes a tunnel endpoint (gRPC/HTTP2) to tunnel HTTP traffic to multiple clients.
- Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT). - Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT).
=== Main components to draw === === Main components to draw ===
@@ -19,8 +19,8 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Terminates TLS using ACME certificates for main and proxy domains. - Terminates TLS using ACME certificates for main and proxy domains.
b. "HTTP Listener (TCP 80)" b. "HTTP Listener (TCP 80)"
- Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS. - Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS.
c. "DTLS Listener (UDP 443 or 8443)" c. "Tunnel Endpoint (gRPC)"
- Terminates DTLS sessions from multiple clients. - gRPC/HTTP2 listener on the same HTTPS port (TCP 443) tunnel streams.
d. "Admin API / Management Plane" d. "Admin API / Management Plane"
- REST API base path: /api/v1/admin - REST API base path: /api/v1/admin
- Endpoints: - Endpoints:
@@ -31,8 +31,9 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Routes incoming HTTP(S) requests to the correct client based on domain and path. - Routes incoming HTTP(S) requests to the correct client based on domain and path.
f. "ACME Certificate Manager" f. "ACME Certificate Manager"
- Automatically issues and renews TLS certificates (Let's Encrypt). - Automatically issues and renews TLS certificates (Let's Encrypt).
g. "DTLS Session Manager" g. "Tunnel Session Manager"
- Manages DTLS connections and per-domain sessions with clients. - Manages tunnel connections and per-domain sessions with clients
(gRPC streams).
h. "Metrics & Logging" h. "Metrics & Logging"
- Structured JSON logs shipped to Prometheus / Loki / Grafana stack. - Structured JSON logs shipped to Prometheus / Loki / Grafana stack.
@@ -50,35 +51,34 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Draw 23 separate client boxes to show that multiple clients can connect. - Draw 23 separate client boxes to show that multiple clients can connect.
- Each box titled "HopGate Client". - Each box titled "HopGate Client".
- Inside each client box, show: - Inside each client box, show:
a. "DTLS Client" a. "Tunnel Client"
- Connects to HopGate Server via DTLS. - gRPC client that opens a long-lived bi-directional gRPC stream over HTTPS (HTTP/2).
- Performs handshake with:
- domain
- client_api_key
b. "Client Proxy" b. "Client Proxy"
- Receives HTTP requests from the server over DTLS. - Receives HTTP request frames from the server over the tunnel (gRPC stream).
- Forwards them to local services such as: - Forwards them to local services such as:
- 127.0.0.1:8080 (web) - 127.0.0.1:8080 (web)
- 127.0.0.1:9000 (admin)
c. "Local Services" c. "Local Services"
- A small group of boxes representing local HTTP servers. - A small group of boxes representing local HTTP servers.
=== Flows to highlight === === Flows to highlight ===
1) User HTTP Flow 1) User HTTP Flow
- External user -> HTTPS Listener -> Reverse Proxy Core -> DTLS Session Manager -> Specific HopGate Client -> Local Service -> back through same path to the user. - External user -> HTTPS Listener -> Reverse Proxy Core ->
gRPC Tunnel Endpoint -> specific HopGate Client (gRPC stream) -> Local Service ->
back through same path to the user.
2) Admin Flow 2) Admin Flow
- Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM: - Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM:
- Register domain + memo -> returns client_api_key. - Register domain + memo -> returns client_api_key.
- Unregister domain + client_api_key. - Unregister domain + client_api_key.
3) DTLS Handshake Flow 3) Tunnel Handshake / Session Establishment Flow
- From client to server over DTLS: - v1: DTLS Handshake Flow (legacy) - (REMOVED)
- Client sends {domain, client_api_key}. - v2: gRPC Tunnel Establishment Flow:
- Server validates against PostgreSQL Domain table. - From client to server over HTTPS (HTTP/2):
- On success, both sides log: - Client opens a long-lived bi-directional gRPC stream (e.g. OpenTunnel).
- server: which domain is bound to the session. - First frame includes {domain, client_api_key} and client metadata.
- client: success message, bound domain, and local_target (local service address). - Server validates against PostgreSQL Domain table and associates the gRPC stream with that domain.
- Subsequent frames carry HTTP request/response metadata and body chunks.
=== Visual style === === Visual style ===
- Clean flat design, no 3D. - Clean flat design, no 3D.

View File

@@ -1,218 +1,58 @@
package dtls package dtls
import ( import (
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net"
"time" "time"
piondtls "github.com/pion/dtls/v3"
) )
// pionSession 은 pion/dtls.Conn 을 감싸 Session 인터페이스를 구현합니다. // PionServerConfig 는 DTLS 서버 리스너 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko)
type pionSession struct { // PionServerConfig keeps the old DTLS server listener configuration shape for compatibility. (en)
conn *piondtls.Conn
id string
}
func (s *pionSession) Read(b []byte) (int, error) { return s.conn.Read(b) }
func (s *pionSession) Write(b []byte) (int, error) { return s.conn.Write(b) }
func (s *pionSession) Close() error { return s.conn.Close() }
func (s *pionSession) ID() string { return s.id }
// pionServer 는 pion/dtls 기반 Server 구현입니다.
type pionServer struct {
listener net.Listener
}
// PionServerConfig 는 DTLS 서버 리스너 구성을 정의합니다.
type PionServerConfig struct { type PionServerConfig struct {
// Addr 는 "0.0.0.0:443" 와 같은 UDP 리스닝 주소입니다.
Addr string Addr string
// TLSConfig 는 ACME 등을 통해 준비된 tls.Config 입니다.
// Certificates, RootCAs, ClientAuth 등의 설정이 여기서 넘어옵니다.
// nil 인 경우 기본 빈 tls.Config 가 사용됩니다.
TLSConfig *tls.Config TLSConfig *tls.Config
} }
// NewPionServer 는 pion/dtls 기반 DTLS 서버를 생성합니다. // PionClientConfig 는 DTLS 클라이언트 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko)
// 내부적으로 udp 리스너를 열고, DTLS 핸드셰이크를 수행할 준비를 합니다. // PionClientConfig keeps the old DTLS client configuration shape for compatibility. (en)
func NewPionServer(cfg PionServerConfig) (Server, error) {
if cfg.Addr == "" {
return nil, fmt.Errorf("PionServerConfig.Addr is required")
}
if cfg.TLSConfig == nil {
cfg.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
udpAddr, err := net.ResolveUDPAddr("udp", cfg.Addr)
if err != nil {
return nil, fmt.Errorf("resolve udp addr: %w", err)
}
// tls.Config.GetCertificate (crypto/tls) → pion/dtls.GetCertificate 어댑터
var getCert func(*piondtls.ClientHelloInfo) (*tls.Certificate, error)
if cfg.TLSConfig.GetCertificate != nil {
tlsGetCert := cfg.TLSConfig.GetCertificate
getCert = func(chi *piondtls.ClientHelloInfo) (*tls.Certificate, error) {
if chi == nil {
return tlsGetCert(&tls.ClientHelloInfo{})
}
// ACME 매니저는 주로 SNI(ServerName)에 기반해 인증서를 선택하므로,
// 필요한 최소 필드만 복사해서 전달한다.
return tlsGetCert(&tls.ClientHelloInfo{
ServerName: chi.ServerName,
})
}
}
dtlsCfg := &piondtls.Config{
// 서버가 사용할 인증서 설정: 정적 Certificates + GetCertificate 어댑터
Certificates: cfg.TLSConfig.Certificates,
GetCertificate: getCert,
InsecureSkipVerify: cfg.TLSConfig.InsecureSkipVerify,
ClientAuth: piondtls.ClientAuthType(cfg.TLSConfig.ClientAuth),
ClientCAs: cfg.TLSConfig.ClientCAs,
RootCAs: cfg.TLSConfig.RootCAs,
ServerName: cfg.TLSConfig.ServerName,
// 필요 시 ExtendedMasterSecret 등을 추가 설정
}
l, err := piondtls.Listen("udp", udpAddr, dtlsCfg)
if err != nil {
return nil, fmt.Errorf("dtls listen: %w", err)
}
return &pionServer{
listener: l,
}, nil
}
// Accept 는 새로운 DTLS 연결을 수락하고, Session 으로 래핑합니다.
func (s *pionServer) Accept() (Session, error) {
conn, err := s.listener.Accept()
if err != nil {
return nil, err
}
dtlsConn, ok := conn.(*piondtls.Conn)
if !ok {
_ = conn.Close()
return nil, fmt.Errorf("accepted connection is not *dtls.Conn")
}
id := ""
if ra := dtlsConn.RemoteAddr(); ra != nil {
id = ra.String()
}
return &pionSession{
conn: dtlsConn,
id: id,
}, nil
}
// Close 는 DTLS 리스너를 종료합니다.
func (s *pionServer) Close() error {
return s.listener.Close()
}
// pionClient 는 pion/dtls 기반 Client 구현입니다.
type pionClient struct {
addr string
tlsConfig *tls.Config
timeout time.Duration
}
// PionClientConfig 는 DTLS 클라이언트 구성을 정의합니다.
type PionClientConfig struct { type PionClientConfig struct {
// Addr 는 서버의 UDP 주소 (예: "example.com:443") 입니다.
Addr string Addr string
// TLSConfig 는 서버 인증에 사용할 tls.Config 입니다.
// InsecureSkipVerify=true 로 두면 서버 인증을 건너뛰므로 개발/테스트에만 사용해야 합니다.
TLSConfig *tls.Config TLSConfig *tls.Config
// Timeout 은 DTLS 핸드셰이크 타임아웃입니다.
// 0 이면 기본값 10초가 사용됩니다.
Timeout time.Duration Timeout time.Duration
} }
// NewPionClient 는 pion/dtls 기반 DTLS 클라이언트를 생성합니다. // disabledServer 는 DTLS 전송이 비활성화되었음을 나타내는 더미 구현입니다. (ko)
func NewPionClient(cfg PionClientConfig) Client { // disabledServer is a dummy Server implementation indicating that DTLS transport is disabled. (en)
if cfg.Timeout == 0 { type disabledServer struct{}
cfg.Timeout = 10 * time.Second
} func (s *disabledServer) Accept() (Session, error) {
if cfg.TLSConfig == nil { return nil, fmt.Errorf("dtls transport is disabled; use gRPC tunnel instead")
// 기본값: 인증서 검증을 수행하는 안전한 설정(루트 CA 체인은 시스템 기본값 사용).
// 디버그 모드에서 인증서 검증을 스킵하고 싶다면, 호출 측에서
// TLSConfig: &tls.Config{InsecureSkipVerify: true} 를 명시적으로 전달해야 합니다.
cfg.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}
return &pionClient{
addr: cfg.Addr,
tlsConfig: cfg.TLSConfig,
timeout: cfg.Timeout,
}
} }
// Connect 는 서버와 DTLS 핸드셰이크를 수행하고 Session 을 반환합니다. func (s *disabledServer) Close() error {
func (c *pionClient) Connect() (Session, error) {
if c.addr == "" {
return nil, fmt.Errorf("PionClientConfig.Addr is required")
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
raddr, err := net.ResolveUDPAddr("udp", c.addr)
if err != nil {
return nil, fmt.Errorf("resolve udp addr: %w", err)
}
dtlsCfg := &piondtls.Config{
// 클라이언트는 서버 인증을 위해 RootCAs/ServerName 만 사용.
// (현재는 클라이언트 인증서 사용 계획이 없으므로 GetCertificate 는 전달하지 않는다.)
Certificates: c.tlsConfig.Certificates,
InsecureSkipVerify: c.tlsConfig.InsecureSkipVerify,
RootCAs: c.tlsConfig.RootCAs,
ServerName: c.tlsConfig.ServerName,
}
type result struct {
conn *piondtls.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, err := piondtls.Dial("udp", raddr, dtlsCfg)
ch <- result{conn: conn, err: err}
}()
select {
case <-ctx.Done():
return nil, fmt.Errorf("dtls dial timeout: %w", ctx.Err())
case res := <-ch:
if res.err != nil {
return nil, fmt.Errorf("dtls dial: %w", res.err)
}
id := ""
if ra := res.conn.RemoteAddr(); ra != nil {
id = ra.String()
}
return &pionSession{
conn: res.conn,
id: id,
}, nil
}
}
// Close 는 클라이언트 단에서 유지하는 리소스가 없으므로 no-op 입니다.
func (c *pionClient) Close() error {
return nil return nil
} }
// disabledClient 는 DTLS 전송이 비활성화되었음을 나타내는 더미 구현입니다. (ko)
// disabledClient is a dummy Client implementation indicating that DTLS transport is disabled. (en)
type disabledClient struct{}
func (c *disabledClient) Connect() (Session, error) {
return nil, fmt.Errorf("dtls transport is disabled; use gRPC tunnel instead")
}
func (c *disabledClient) Close() error {
return nil
}
// NewPionServer 는 더 이상 실제 DTLS 서버를 생성하지 않고, 항상 에러를 반환합니다. (ko)
// NewPionServer no longer creates a real DTLS server and always returns an error. (en)
func NewPionServer(cfg PionServerConfig) (Server, error) {
return nil, fmt.Errorf("dtls transport is disabled; NewPionServer is no longer supported")
}
// NewPionClient 는 더 이상 실제 DTLS 클라이언트를 생성하지 않고, disabledClient 를 반환합니다. (ko)
// NewPionClient no longer creates a real DTLS client and instead returns a disabledClient. (en)
func NewPionClient(cfg PionClientConfig) Client {
return &disabledClient{}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css --> <!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css -->
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -5,6 +5,7 @@
<title>404 Not Found - HopGate</title> <title>404 Not Found - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -5,6 +5,7 @@
<title>500 Internal Server Error - HopGate</title> <title>500 Internal Server Error - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -5,6 +5,7 @@
<title>502 Bad Gateway - HopGate</title> <title>502 Bad Gateway - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -5,6 +5,7 @@
<title>504 Gateway Timeout - HopGate</title> <title>504 Gateway Timeout - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -5,6 +5,7 @@
<title>525 TLS Handshake Failed - HopGate</title> <title>525 TLS Handshake Failed - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package hopgate.protocol.v1; package hopgate.protocol.v1;
option go_package = "github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpb"; option go_package = "internal/protocol/pb;pb";
// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다. // HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다.
// HeaderValues wraps multiple header values for a single HTTP header key. // HeaderValues wraps multiple header values for a single HTTP header key.

View File

@@ -718,7 +718,7 @@ const file_internal_protocol_hopgate_stream_proto_rawDesc = "" +
"\fstream_close\x18\x05 \x01(\v2 .hopgate.protocol.v1.StreamCloseH\x00R\vstreamClose\x12?\n" + "\fstream_close\x18\x05 \x01(\v2 .hopgate.protocol.v1.StreamCloseH\x00R\vstreamClose\x12?\n" +
"\n" + "\n" +
"stream_ack\x18\x06 \x01(\v2\x1e.hopgate.protocol.v1.StreamAckH\x00R\tstreamAckB\t\n" + "stream_ack\x18\x06 \x01(\v2\x1e.hopgate.protocol.v1.StreamAckH\x00R\tstreamAckB\t\n" +
"\apayloadB@Z>github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpbb\x06proto3" "\apayloadB\x19Z\x17internal/protocol/pb;pbb\x06proto3"
var ( var (
file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once

View File

@@ -0,0 +1,119 @@
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// HopGateTunnelClient is the client API for the HopGateTunnel service.
type HopGateTunnelClient interface {
// OpenTunnel establishes a long-lived bi-directional stream between
// a HopGate client and the server. Both HTTP requests and responses
// are multiplexed as Envelope messages on this stream.
OpenTunnel(ctx context.Context, opts ...grpc.CallOption) (HopGateTunnel_OpenTunnelClient, error)
}
type hopGateTunnelClient struct {
cc grpc.ClientConnInterface
}
// NewHopGateTunnelClient creates a new HopGateTunnelClient.
func NewHopGateTunnelClient(cc grpc.ClientConnInterface) HopGateTunnelClient {
return &hopGateTunnelClient{cc: cc}
}
func (c *hopGateTunnelClient) OpenTunnel(ctx context.Context, opts ...grpc.CallOption) (HopGateTunnel_OpenTunnelClient, error) {
stream, err := c.cc.NewStream(ctx, &_HopGateTunnel_serviceDesc.Streams[0], "/hopgate.protocol.v1.HopGateTunnel/OpenTunnel", opts...)
if err != nil {
return nil, err
}
return &hopGateTunnelOpenTunnelClient{ClientStream: stream}, nil
}
// HopGateTunnel_OpenTunnelClient is the client-side stream for OpenTunnel.
type HopGateTunnel_OpenTunnelClient interface {
Send(*Envelope) error
Recv() (*Envelope, error)
grpc.ClientStream
}
type hopGateTunnelOpenTunnelClient struct {
grpc.ClientStream
}
func (x *hopGateTunnelOpenTunnelClient) Send(m *Envelope) error {
return x.ClientStream.SendMsg(m)
}
func (x *hopGateTunnelOpenTunnelClient) Recv() (*Envelope, error) {
m := new(Envelope)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// HopGateTunnelServer is the server API for the HopGateTunnel service.
type HopGateTunnelServer interface {
// OpenTunnel handles a long-lived bi-directional stream between the server
// and a HopGate client. Implementations are responsible for reading and
// writing Envelope messages on the stream.
OpenTunnel(HopGateTunnel_OpenTunnelServer) error
}
// UnimplementedHopGateTunnelServer can be embedded to have forward compatible implementations.
type UnimplementedHopGateTunnelServer struct{}
// OpenTunnel returns an Unimplemented error by default.
func (UnimplementedHopGateTunnelServer) OpenTunnel(HopGateTunnel_OpenTunnelServer) error {
return status.Errorf(codes.Unimplemented, "method OpenTunnel not implemented")
}
// RegisterHopGateTunnelServer registers the HopGateTunnel service with the given gRPC server.
func RegisterHopGateTunnelServer(s grpc.ServiceRegistrar, srv HopGateTunnelServer) {
s.RegisterService(&_HopGateTunnel_serviceDesc, srv)
}
// HopGateTunnel_OpenTunnelServer is the server-side stream for OpenTunnel.
type HopGateTunnel_OpenTunnelServer interface {
Send(*Envelope) error
Recv() (*Envelope, error)
grpc.ServerStream
}
func _HopGateTunnel_OpenTunnel_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(HopGateTunnelServer).OpenTunnel(&hopGateTunnelOpenTunnelServer{ServerStream: stream})
}
type hopGateTunnelOpenTunnelServer struct {
grpc.ServerStream
}
func (x *hopGateTunnelOpenTunnelServer) Send(m *Envelope) error {
return x.ServerStream.SendMsg(m)
}
func (x *hopGateTunnelOpenTunnelServer) Recv() (*Envelope, error) {
m := new(Envelope)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
var _HopGateTunnel_serviceDesc = grpc.ServiceDesc{
ServiceName: "hopgate.protocol.v1.HopGateTunnel",
HandlerType: (*HopGateTunnelServer)(nil),
Streams: []grpc.StreamDesc{
{
StreamName: "OpenTunnel",
Handler: _HopGateTunnel_OpenTunnel_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "internal/protocol/hopgate_stream.proto",
}

View File

@@ -14,7 +14,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/dalbodeule/hop-gate/internal/dtls"
"github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/logging"
"github.com/dalbodeule/hop-gate/internal/protocol" "github.com/dalbodeule/hop-gate/internal/protocol"
) )
@@ -144,9 +143,9 @@ type streamReceiver struct {
// Input channel for envelopes dispatched from the central readLoop. (en) // Input channel for envelopes dispatched from the central readLoop. (en)
inCh chan *protocol.Envelope inCh chan *protocol.Envelope
// DTLS 세션 및 직렬화 codec / 로깅 핸들. (ko) // 세션(write 방향) 및 직렬화 codec / 로깅 핸들. (ko)
// DTLS session, wire codec and logging handles. (en) // Session (write side only), wire codec and logging handles. (en)
sess dtls.Session sess io.ReadWriter
codec protocol.WireCodec codec protocol.WireCodec
logger logging.Logger logger logging.Logger
@@ -161,7 +160,7 @@ type streamReceiver struct {
// newStreamReceiver initializes a streamReceiver for a single stream ID. (en) // newStreamReceiver initializes a streamReceiver for a single stream ID. (en)
func newStreamReceiver( func newStreamReceiver(
id protocol.StreamID, id protocol.StreamID,
sess dtls.Session, sess io.ReadWriter,
codec protocol.WireCodec, codec protocol.WireCodec,
logger logging.Logger, logger logging.Logger,
httpClient *http.Client, httpClient *http.Client,
@@ -604,7 +603,7 @@ func (p *ClientProxy) getStreamSender(id protocol.StreamID) *streamSender {
// - `handleStreamRequest` 내부 HTTP 매핑 로직을 `streamReceiver` 로 옮기고, // - `handleStreamRequest` 내부 HTTP 매핑 로직을 `streamReceiver` 로 옮기고,
// - StartLoop 가 DTLS 세션 → per-stream goroutine 으로 이벤트를 분배하는 역할만 수행하도록 // - StartLoop 가 DTLS 세션 → per-stream goroutine 으로 이벤트를 분배하는 역할만 수행하도록
// 점진적으로 리팩터링할 예정입니다. // 점진적으로 리팩터링할 예정입니다.
func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { func (p *ClientProxy) StartLoop(ctx context.Context, sess io.ReadWriter) error {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@@ -839,7 +838,7 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
// handleHTTPEnvelope 는 기존 단일 HTTP 요청/응답 Envelope 경로를 처리합니다. (ko) // handleHTTPEnvelope 는 기존 단일 HTTP 요청/응답 Envelope 경로를 처리합니다. (ko)
// handleHTTPEnvelope handles the legacy single HTTP request/response envelope path. (en) // handleHTTPEnvelope handles the legacy single HTTP request/response envelope path. (en)
func (p *ClientProxy) handleHTTPEnvelope(ctx context.Context, sess dtls.Session, env *protocol.Envelope) error { func (p *ClientProxy) handleHTTPEnvelope(ctx context.Context, sess io.ReadWriter, env *protocol.Envelope) error {
if env.HTTPRequest == nil { if env.HTTPRequest == nil {
return fmt.Errorf("http envelope missing http_request payload") return fmt.Errorf("http envelope missing http_request payload")
} }
@@ -896,7 +895,7 @@ func (p *ClientProxy) handleHTTPEnvelope(ctx context.Context, sess dtls.Session,
// handleStreamRequest 는 StreamOpen/StreamData/StreamClose 기반 HTTP 요청/응답 스트림을 처리합니다. (ko) // handleStreamRequest 는 StreamOpen/StreamData/StreamClose 기반 HTTP 요청/응답 스트림을 처리합니다. (ko)
// handleStreamRequest handles an HTTP request/response exchange using StreamOpen/StreamData/StreamClose frames. (en) // handleStreamRequest handles an HTTP request/response exchange using StreamOpen/StreamData/StreamClose frames. (en)
func (p *ClientProxy) handleStreamRequest(ctx context.Context, sess dtls.Session, reader io.Reader, openEnv *protocol.Envelope) error { func (p *ClientProxy) handleStreamRequest(ctx context.Context, sess io.ReadWriter, reader io.Reader, openEnv *protocol.Envelope) error {
codec := protocol.DefaultCodec codec := protocol.DefaultCodec
log := p.Logger log := p.Logger

View File

@@ -224,286 +224,46 @@ This document tracks implementation progress against the HopGate architecture an
--- ---
### 3.3 Proxy Core / HTTP Tunneling ### 3.3 Proxy Core / gRPC Tunneling
- [ ] 서버 측 Proxy 구현 확장: [`internal/proxy/server.go`](internal/proxy/server.go) HopGate 의 최종 목표는 **TCP + TLS(HTTPS) + HTTP/2 + gRPC** 기반 터널로 HTTP 트래픽을 전달하는 것입니다.
- 현재 `ServerProxy` / `Router` 인터페이스와 `NewHTTPServer` 만 정의되어 있고, 이 섹션에서는 DTLS 기반 초기 설계를 정리만 남기고, 실제 구현/남은 작업은 gRPC 터널 기준으로 재정의합니다.
실제 HTTP/HTTPS 리스너와 DTLS 세션 매핑 로직은 [`cmd/server/main.go`](cmd/server/main.go) 의
`newHTTPHandler` / `dtlsSessionWrapper.ForwardHTTP` 안에 위치합니다.
- Proxy 코어 로직을 proxy 레이어로 이동하는 리팩터링은 아직 진행되지 않았습니다. (3.6 항목과 연동)
- [x] 클라이언트 측 Proxy 구현 확장: [`internal/proxy/client.go`](internal/proxy/client.go) - [x] 서버 측 gRPC 터널 엔드포인트 설계/구현
- DTLS 세션에서 `protocol.Request` 수신 → 로컬 HTTP 호출 → `protocol.Response` 전송 루프 구현. - 외부 사용자용 HTTPS(443/TCP)와 같은 포트에서:
- timeout/취소/에러 처리. - 일반 HTTP 요청(브라우저/REST)은 기존 리버스 프록시 경로로,
- `Content-Type: application/grpc` 인 요청은 클라이언트 터널용 gRPC 서버로
라우팅하는 구조를 설계합니다.
- 예시: `rpc OpenTunnel(stream TunnelFrame) returns (stream TunnelFrame)` (bi-directional streaming).
- HTTP/2 + ALPN(h2)을 사용해 gRPC 스트림을 유지하고, 요청/응답 HTTP 메시지를 `TunnelFrame`으로 멀티플렉싱합니다.
- [x] 서버 main 에 Proxy wiring 추가: [`cmd/server/main.go`](cmd/server/main.go) - [x] 클라이언트 측 gRPC 터널 설계/구현
- DTLS handshake 완료된 세션을 Proxy 라우팅 테이블에 등록. - 클라이언트 프로세스는 HopGate 서버로 장기 유지 bi-di gRPC 스트림을 **하나(또는 소수 개)** 연 상태로 유지합니다.
- HTTPS 서버와 Proxy 핸들러 연결. - 서버로부터 들어오는 `TunnelFrame`(요청 메타데이터 + 바디 chunk)을 수신해,
로컬 HTTP 서비스(예: `127.0.0.1:8080`)로 proxy 하고, 응답을 다시 `TunnelFrame` 시퀀스로 전송합니다.
- 기존 `internal/proxy/client.go` 의 HTTP 매핑/스트림 ARQ 경험을, gRPC 메시지 단위 chunk/flow-control 설계에 참고합니다.
- [x] 클라이언트 main 에 Proxy loop wiring 추가: [`cmd/client/main.go`](cmd/client/main.go) - [x] HTTP ↔ gRPC 터널 매핑 규약 정의
- handshake 성공 후 `proxy.ClientProxy.StartLoop` 실행. - 한 HTTP 요청/응답 쌍을 gRPC 스트림 상에서 어떻게 표현할지 스키마를 정의합니다:
- 요청: `StreamID`, method, URL, headers, body chunks
- 응답: `StreamID`, status, headers, body chunks, error
- 현재 `internal/protocol/protocol.go`의 논리 모델(Envelope/StreamOpen/StreamData/StreamClose/StreamAck)을
gRPC 메시지(oneof 필드 등)로 직렬화할지, 또는 새로운 gRPC 전용 메시지를 정의할지 결정합니다.
- Back-pressure / flow-control 은 gRPC/HTTP2의 스트림 flow-control 을 최대한 활용하고,
추가 application-level windowing 이 필요하면 최소한으로만 도입합니다.
#### 3.3A Stream-based DTLS Tunneling / 스트림 기반 DTLS 터널링 - [ ] gRPC 터널 기반 E2E 플로우 정의/테스트 계획
- 하나의 gRPC 스트림 위에서:
초기 HTTP 터널링 설계는 **단일 JSON Envelope + 단일 DTLS 쓰기** 방식(요청/응답 바디 전체를 한 번에 전송)이었고, - 동시에 여러 정적 리소스(`/css`, `/js`, `/img`) 요청,
대용량 응답 바디에서 UDP MTU 한계로 인한 `sendto: message too long` 문제가 발생할 수 있었습니다. - 큰 응답(수 MB 파일)과 작은 응답(API JSON)이 섞여 있는 시나리오,
이 한계를 제거하기 위해, 현재 코드는 DTLS 위 애플리케이션 프로토콜을 **스트림/프레임 기반**으로 재설계하여 `StreamOpen` / `StreamData` / `StreamClose` 를 사용합니다. - 클라이언트 재시작/네트워크 단절 후 재연결 시나리오
The initial tunneling model used a **single JSON envelope + single DTLS write per HTTP message**, which could hit UDP MTU limits (`sendto: message too long`) for large bodies. 를 포함하는 테스트 플랜을 작성합니다.
The current implementation uses a **stream/frame-based** protocol over DTLS (`StreamOpen` / `StreamData` / `StreamClose`), and this section documents its constraints and further improvements (e.g. ARQ).
고려해야 할 제약 / Constraints:
- 전송 계층은 DTLS(pion/dtls)를 유지합니다.
The transport layer must remain DTLS (pion/dtls).
- JSON 기반 단일 Envelope 모델에서 벗어나, HTTP 바디를 안전한 크기의 chunk 로 나누어 전송해야 합니다.
We must move away from the single-envelope JSON model and chunk HTTP bodies under a safe MTU.
- UDP 특성상 일부 프레임 손실/오염에 대비해, **해당 chunk 만 재전송 요청할 수 있는 ARQ 메커니즘**이 필요합니다.
Given UDP characteristics, we need an application-level ARQ so that **only lost/corrupted chunks are retransmitted**.
아래 단계들은 `feature/udp-stream` 브랜치에서 구현할 구체적인 작업 항목입니다.
The following tasks describe concrete work items to be implemented on the `feature/udp-stream` branch.
---
##### 3.3A.1 스트림 프레이밍 프로토콜 설계 (JSON 1단계)
##### 3.3A.1 Stream framing protocol (JSON, phase 1)
- [x] 스트림 프레임 타입 정리 및 확장: [`internal/protocol/protocol.go`](internal/protocol/protocol.go:35)
- 이미 정의된 스트림 관련 타입을 1단계에서 적극 활용합니다.
Reuse the already defined stream-related types in phase 1:
- `MessageTypeStreamOpen`, `MessageTypeStreamData`, `MessageTypeStreamClose`
- [`Envelope`](internal/protocol/protocol.go:52), [`StreamOpen`](internal/protocol/protocol.go:69), [`StreamData`](internal/protocol/protocol.go:80), [`StreamClose`](internal/protocol/protocol.go:86)
- `StreamData` 에 per-stream 시퀀스 번호를 추가합니다.
Add a per-stream sequence number to `StreamData`:
- 예시 / Example:
```go
type StreamData struct {
ID StreamID `json:"id"`
Seq uint64 `json:"seq"` // 0부터 시작하는 per-stream sequence
Data []byte `json:"data"`
}
```
- [x] 스트림 ACK / 재전송 제어 메시지 추가: [`internal/protocol/protocol.go`](internal/protocol/protocol.go:52)
- 선택적 재전송(Selective Retransmission)을 위해 `StreamAck` 메시지와 `MessageTypeStreamAck` 를 추가합니다.
Add `StreamAck` message and `MessageTypeStreamAck` for selective retransmission:
```go
const (
MessageTypeStreamAck MessageType = "stream_ack"
)
type StreamAck struct {
ID StreamID `json:"id"` // 대상 스트림 / target stream
AckSeq uint64 `json:"ack_seq"` // 연속으로 수신 완료한 마지막 Seq / last contiguous sequence
LostSeqs []uint64 `json:"lost_seqs"` // 누락된 시퀀스 목록(선택) / optional list of missing seqs
WindowSize uint32 `json:"window_size"` // 선택: 허용 in-flight 프레임 수 / optional receive window
}
```
- [`Envelope`](internal/protocol/protocol.go:52)에 `StreamAck *StreamAck` 필드를 추가합니다.
Extend `Envelope` with a `StreamAck *StreamAck` field.
- [x] MTU-safe chunk 크기 정의
- DTLS/UDP 헤더 및 Protobuf/length-prefix 오버헤드를 고려해 안전한 payload 크기(4KiB)를 상수로 정의합니다.
Define a safe payload size constant (4KiB) considering DTLS/UDP headers and Protobuf/length-prefix framing.
- 이 값은 [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32) 의 `StreamChunkSize` 로 정의되었습니다.
Implemented as `StreamChunkSize` in [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32).
- 이후 HTTP 바디 스트림 터널링 구현 시, 모든 `StreamData.Data` 는 이 크기 이하 chunk 로 잘라 전송해야 합니다.
In the stream tunneling implementation, every `StreamData.Data` must be sliced into chunks no larger than this size.
---
##### 3.3A.2 애플리케이션 레벨 ARQ 설계 (Selective Retransmission)
##### 3.3A.2 Application-level ARQ (Selective Retransmission)
- [x] 수신 측 ARQ 상태 관리 구현
- 스트림별로 `expectedSeq`, out-of-order chunk 버퍼(`received`), 누락 시퀀스 집합(`lost`)을 유지하면서,
in-order / out-of-order 프레임을 구분해 HTTP 바디 버퍼에 순서대로 쌓습니다.
- For each stream, maintain `expectedSeq`, an out-of-order buffer (`received`), and a lost-sequence set (`lost`),
delivering in-order frames directly to the HTTP body buffer while buffering/reordering out-of-order ones.
- [x] 수신 측 StreamAck 전송 정책 구현
- 각 `StreamData` 수신 시점에 `AckSeq = expectedSeq - 1` 과 현재 윈도우에서 누락된 시퀀스 일부(`LostSeqs`, 상한 개수 적용)를 포함한
`StreamAck{AckSeq, LostSeqs}` 를 전송해 선택적 재전송을 유도합니다.
- On every `StreamData` frame, send `StreamAck{AckSeq, LostSeqs}` where `AckSeq = expectedSeq - 1` and `LostSeqs`
contains a bounded set (up to a fixed limit) of missing sequence numbers in the current receive window.
- [x] 송신 측 재전송 로직 구현 (StreamAck 기반)
- 응답 스트림 송신 측에서 스트림별 `streamSender` 를 두고, `outstanding[seq] = payload` 로 아직 Ack 되지 않은 프레임을 추적합니다.
- `StreamAck{AckSeq, LostSeqs}` 수신 시:
- `seq <= AckSeq` 인 항목은 모두 제거하고,
- `LostSeqs` 에 포함된 시퀀스에 대해서만 `StreamData{ID, Seq, Data}` 를 재전송합니다.
- A per-stream `streamSender` tracks `outstanding[seq] = payload` for unacknowledged frames. Upon receiving
`StreamAck{AckSeq, LostSeqs}`, it deletes all `seq <= AckSeq` and retransmits only frames whose sequence
numbers appear in `LostSeqs`.
> Note: 현재 구현은 StreamAck 기반 **선택적 재전송(Selective Retransmission)** 까지 포함하며,
> 별도의 RTO(재전송 타이머) 기반 백그라운드 재전송 루프는 향후 확장 여지로 남겨둔 상태입니다.
> Note: The current implementation covers StreamAck-based **selective retransmission**; a separate RTO-based
> background retransmission loop is left as a potential future enhancement.
---
##### 3.3A.3 HTTP ↔ 스트림 매핑 (서버/클라이언트)
##### 3.3A.3 HTTP ↔ stream mapping (server/client)
- [x] 서버 → 클라이언트 요청 스트림: [`cmd/server/main.go`](cmd/server/main.go:200)
- `ForwardHTTP` 는 스트림 기반 HTTP 요청/응답을 처리하도록 구현되어 있으며, 동작은 다음과 같습니다.
`ForwardHTTP` is implemented in stream mode and behaves as follows:
- HTTP 요청 수신 시:
- 새로운 `StreamID` 를 발급합니다 (세션별 증가).
Generate a new `StreamID` per incoming HTTP request on the DTLS session.
- `StreamOpen` 전송:
- 요청 메서드/URL/헤더를 [`StreamOpen`](internal/protocol/protocol.go:69) 의 `Header` 혹은 pseudo-header 로 encode.
Encode method/URL/headers into `StreamOpen.Header` or a pseudo-header scheme.
- 요청 바디를 읽으면서 `StreamData{ID, Seq, Data}` 를 지속적으로 전송합니다.
Read the HTTP request body and send it as a sequence of `StreamData` frames.
- 바디 종료 시 `StreamClose{ID, Error:""}` 를 전송합니다.
When the body ends, send `StreamClose{ID, Error:""}`.
- 응답 수신:
- 클라이언트에서 오는 역방향 `StreamOpen` 으로 HTTP status/header 를 수신하고,
이를 `http.ResponseWriter` 에 반영합니다.
Receive response status/headers via reverse-direction `StreamOpen` and map them to `http.ResponseWriter`.
- 연속되는 `StreamData` 를 수신할 때마다 `http.ResponseWriter.Write` 로 chunk 를 바로 전송합니다.
For each `StreamData`, write the chunk directly to the HTTP response.
- `StreamClose` 수신 시 응답 종료 및 스트림 자원 정리.
On `StreamClose`, finish the response and clean up per-stream state.
- [x] 클라이언트에서의 요청 처리 스트림: [`internal/proxy/client.go`](internal/proxy/client.go:200)
- 서버로부터 들어오는 `StreamOpen{ID, ...}` 을 수신하면,
새로운 goroutine 을 띄워 해당 ID에 대한 로컬 HTTP 요청을 수행합니다.
On receiving `StreamOpen{ID, ...}` from the server, spawn a goroutine to handle the local HTTP request for that stream ID.
- 스트림별로 `io.Pipe` 또는 채널 기반 바디 리더를 준비하고,
`StreamData` 프레임을 수신할 때마다 이 파이프에 쓰도록 합니다.
Prepare an `io.Pipe` (or channel-backed reader) per stream and write incoming `StreamData` chunks into it.
- 로컬 HTTP 클라이언트 응답은 반대로:
For the local HTTP client response:
- 응답 status/header → `StreamOpen` (client → server)
- 응답 바디 → 여러 개의 `StreamData`
- 종료 시점에 `StreamClose` 전송
Send `StreamOpen` (status/headers), then a sequence of `StreamData`, followed by `StreamClose` when done.
---
##### 3.3A.4 JSON → 바이너리 직렬화로의 잠재적 전환 (2단계)
##### 3.3A.4 JSON → binary serialization (potential phase 2)
- [x] JSON 기반 스트림 프로토콜의 1단계 구현/안정화 이후, 직렬화 포맷 재검토 및 Protobuf 전환
- 현재는 JSON 대신 Protobuf length-prefix `Envelope` 포맷을 기본으로 사용합니다.
The runtime now uses a Protobuf-based, length-prefixed `Envelope` format instead of JSON.
- HTTP/스트림 payload 는 여전히 MTU-safe 크기(예: 4KiB, `StreamChunkSize`)로 제한되어 있어, 단일 프레임이 과도하게 커지지 않습니다.
HTTP/stream payloads remain bounded to an MTU-safe size (e.g. 4KiB via `StreamChunkSize`), so individual frames stay small.
- [x] length-prefix 이진 프레임(Protobuf)으로 전환
- 동일한 logical model (`StreamOpen` / `StreamData(seq)` / `StreamClose` / `StreamAck`)을 유지한 채,
wire-format 을 Protobuf length-prefix binary 프레이밍으로 교체했고, 이는 `protobufCodec` 으로 구현되었습니다.
We now keep the same logical model while using Protobuf length-prefixed framing via `protobufCodec`.
- [x] 이 전환은 `internal/protocol` 내 직렬화 레이어를 얇은 abstraction 으로 감싸 구현했습니다.
- [`internal/protocol/codec.go`](internal/protocol/codec.go:130) 에 `WireCodec` 인터페이스와 Protobuf 기반 `DefaultCodec` 을 도입해,
호출자는 `protocol.DefaultCodec` 만 사용하고, JSON codec 은 보조 용도로만 남아 있습니다.
In [`internal/protocol/codec.go`](internal/protocol/codec.go:130), the `WireCodec` abstraction and Protobuf-based `DefaultCodec` allow callers to use only `protocol.DefaultCodec` while JSON remains as an auxiliary codec.
---
##### 3.3B DTLS Session Multiplexing / 세션 내 다중 HTTP 요청 처리
현재 구현은 클라이언트 측에서 단일 DTLS 세션 내에 **동시에 하나의 HTTP 요청 스트림만** 처리할 수 있습니다.
`ClientProxy.handleStreamRequest` 가 DTLS 세션의 reader 를 직접 소비하기 때문에, 동일 세션에서 두 번째 `StreamOpen` 이 섞여 들어오면 프로토콜 위반으로 간주되고 세션이 끊어집니다.
이 섹션은 **클라이언트 측 스트림 demux + per-stream goroutine 구조**를 도입해, 하나의 DTLS 세션 안에서 여러 HTTP 요청을 안전하게 병렬 처리하기 위한 단계입니다.
Currently, the client can effectively handle **only one HTTP request stream at a time per DTLS session**.
Because `ClientProxy.handleStreamRequest` directly consumes the DTLS session reader, an additional `StreamOpen` for a different stream interleaving on the same session is treated as a protocol error and tears down the session.
This section introduces a **client-side stream demultiplexer + per-stream goroutines** to safely support multiple concurrent HTTP requests within a single DTLS session.
---
##### 3.3B.1 클라이언트 측 중앙 readLoop → 스트림 demux 설계
##### 3.3B.1 Design client-side central readLoop → per-stream demux
- [x] `ClientProxy.StartLoop` 의 역할을 명확히 분리
- DTLS 세션에서 `Envelope` 를 연속해서 읽어들이는 **중앙 readLoop** 를 유지하되,
- 개별 스트림의 HTTP 처리 로직(현재 `handleStreamRequest` 내부 로직)을 분리해 별도 타입/구조체로 옮길 계획을 문서화합니다.
- [x] 스트림 demux 위한 자료구조 설계
- `map[protocol.StreamID]*streamReceiver` 형태의 수신측 스트림 상태 테이블을 정의합니다.
- 각 `streamReceiver` 는 자신만의 입력 채널(예: `inCh chan *protocol.Envelope`)을 가져, 중앙 readLoop 로부터 `StreamOpen/StreamData/StreamClose` 를 전달받도록 합니다.
- [x] 중앙 readLoop 에서 스트림별 라우팅 규칙 정의
- `Envelope.Type` 에 따라:
- `StreamOpen` / `StreamData` / `StreamClose`:
- `streamID` 를 추출하고, 해당 `streamReceiver` 의 `inCh` 로 전달.
- `StreamOpen` 수신 시에는 아직 없는 경우 `streamReceiver` 를 생성 후 등록.
- `StreamAck`:
- 송신 측 ARQ(`streamSender`) 용 테이블(이미 구현된 구조)을 찾아 재전송 로직으로 전달.
- 이 설계를 통해 중앙 readLoop 는 **DTLS 세션 → 스트림 단위 이벤트 분배**만 담당하도록 제한합니다.
---
##### 3.3B.2 streamReceiver 타입 설계 및 HTTP 매핑 리팩터링
##### 3.3B.2 Design streamReceiver type and refactor HTTP mapping
- [x] `streamReceiver` 타입 정의
- 필드 예시:
- `id protocol.StreamID`
- 수신 ARQ 상태: `expectedSeq`, `received map[uint64][]byte`, `lost map[uint64]struct{}`
- 입력 채널: `inCh chan *protocol.Envelope`
- DTLS 세션/codec/logging 핸들: `sess dtls.Session`, `codec protocol.WireCodec`, `logger logging.Logger`
- 로컬 HTTP 호출 관련: `HTTPClient *http.Client`, `LocalTarget string`
- 역할:
- 서버에서 온 `StreamOpen`/`StreamData`/`StreamClose` 를 순서대로 처리해 로컬 HTTP 요청을 구성하고,
- 로컬 HTTP 응답을 다시 `StreamOpen`/`StreamData`/`StreamClose` 로 역방향 전송합니다.
- [x] 기존 `ClientProxy.handleStreamRequest` 의 로직을 `streamReceiver` 로 이전
- 현재 `handleStreamRequest` 안에서 수행하던 작업을 단계적으로 옮깁니다:
- `StreamOpen` 의 pseudo-header 에서 HTTP 메서드/URL/헤더를 복원.
- 요청 바디 수신용 수신 측 ARQ(`expectedSeq`, `received`, `lost`) 처리.
- 로컬 HTTP 요청 생성/실행 및 에러 처리.
- 응답을 4KiB `StreamData` chunk 로 전송 + 송신 측 ARQ(`streamSender.register`) 기록.
- 이때 **DTLS reader 를 직접 읽던 부분**은 제거하고, 대신 `inCh` 에서 전달된 `Envelope` 만 사용하도록 리팩터링합니다.
- [x] streamReceiver 생명주기 관리
- `StreamClose` 수신 시:
- 로컬 HTTP 요청 바디 구성 종료.
- 로컬 HTTP 요청 실행 및 응답 스트림 전송 완료 후,
- `streamReceivers[streamID]` 에서 자신을 제거하고 goroutine 을 종료하는 정책을 명확히 정의합니다.
---
##### 3.3B.3 StartLoop 와 streamReceiver 통합
##### 3.3B.3 Integrate StartLoop and streamReceiver
- [x] `ClientProxy.StartLoop` 을 “중앙 readLoop + demux” 로 단순화
- `MessageTypeStreamOpen` 수신 시:
- `streamID := env.StreamOpen.ID` 를 기준으로 기존 `streamReceiver` 존재 여부를 검사.
- 없으면 새 `streamReceiver` 생성 후, goroutine 을 띄우고 `inCh <- env` 로 첫 메시지 전달.
- `MessageTypeStreamData` / `MessageTypeStreamClose` 수신 시:
- 해당 `streamReceiver` 의 `inCh` 로 그대로 전달.
- `MessageTypeStreamAck` 는 기존처럼 송신 측 `streamSender` 로 라우팅.
- [x] 에러/종료 처리 전략 정리
- 개별 `streamReceiver` 에서 발생하는 에러는:
- 로컬 HTTP 에러 → 스트림 응답에 5xx/에러 바디로 반영.
- 프로토콜 위반(예: 잘못된 순서의 `StreamClose`) → 해당 스트림만 정리하고 세션은 유지하는지 여부를 정의.
- DTLS 세션 레벨 에러(EOF, decode 실패 등)는:
- 모든 `streamReceiver` 의 `inCh` 를 닫고,
- 이후 클라이언트 전체 루프를 종료하는 방향으로 합의합니다.
---
##### 3.3B.4 세션 단위 직렬화 락 제거 및 멀티플렉싱 검증
##### 3.3B.4 Remove session-level serialization lock and validate multiplexing
- [x] 서버 측 세션 직렬화 락 제거 계획 수립
- 현재 서버는 [`dtlsSessionWrapper`](cmd/server/main.go:111)에 `requestMu` 를 두어,
- 동일 DTLS 세션에서 동시에 하나의 `ForwardHTTP` 만 수행하도록 직렬화하고 있습니다.
- 클라이언트 측 멀티플렉싱이 안정화되면, `requestMu` 를 제거하고
- 하나의 세션 안에서 여러 HTTP 요청이 각기 다른 `StreamID` 로 병렬 진행되도록 허용합니다.
- [ ] E2E 멀티플렉싱 테스트 시나리오 정의
- 하나의 DTLS 세션 위에서:
- 동시에 여러 정적 리소스(`/css`, `/js`, `/img`) 요청.
- 큰 응답(수 MB 파일)과 작은 응답(API JSON)이 섞여 있는 시나리오.
- 기대 동작: - 기대 동작:
- 어떤 요청이 느리더라도, 다른 요청이 세션 내부 큐잉 때문에 과도하게 지연되지 않고 병렬로 완료되는지 확인. - 느린 요청이 더라도 다른 요청이 **같은 TCP 연결/스트림 집합 내에서** 과도하게 지연되지 않을 것.
- 클라이언트/서버 로그에 프로토콜 위반(`unexpected envelope type ...`) 이 더 이상 발생하지 않는지 확인. - 서버/클라이언트 로그에 프로토콜 위반 경고(`unexpected frame ...`)가 발생하지 않을 것.
- [ ] 관측성/메트릭에 멀티플렉싱 관련 라벨/필드 추가(선택)
- 필요 시: > Note: 기존 DTLS 기반 스트림/ARQ/멀티플렉싱(3.3A/3.3B)의 작업 내역은
- 세션당 동시 활성 스트림 수, > 구현 경험/아이디어 참고용으로만 유지하며, 신규 기능/운영 계획은 gRPC 터널을 기준으로 진행합니다.
- 스트림 수명(요청-응답 왕복 시간),
- 세션 내 스트림 에러 수
를 관찰할 수 있는 메트릭/로그 필드를 설계합니다.
--- ---
@@ -539,7 +299,7 @@ This section introduces a **client-side stream demultiplexer + per-stream gorout
### 3.6 Hardening / 안정성 & 구성 ### 3.6 Hardening / 안정성 & 구성
- [ ] 설정 유효성 검사 추가 - [x] 설정 유효성 검사 추가
- 필수 env 누락/오류에 대한 명확한 에러 메시지. - 필수 env 누락/오류에 대한 명확한 에러 메시지.
- [ ] 에러 처리/재시도 정책 - [ ] 에러 처리/재시도 정책

197
protocol.md Normal file
View File

@@ -0,0 +1,197 @@
# HopGate gRPC Tunnel Protocol
이 문서는 HopGate 서버–클라이언트 사이의 gRPC 기반 HTTP 터널링 규약을 정리합니다. (ko)
This document describes the gRPC-based HTTP tunneling protocol between HopGate server and clients. (en)
## 1. Transport Overview / 전송 개요
- Transport: TCP + TLS(HTTPS) + HTTP/2 + gRPC
- Single long-lived bi-directional gRPC stream per client: `OpenTunnel`
- Application payload type: `Envelope` (from `internal/protocol/hopgate_stream.proto`)
- HTTP requests/responses are multiplexed as logical streams identified by `StreamID`.
gRPC service (conceptual):
```proto
service HopGateTunnel {
rpc OpenTunnel (stream Envelope) returns (stream Envelope);
}
```
## 2. Message Types / 메시지 타입
Defined in `internal/protocol/hopgate_stream.proto`:
- `HeaderValues`
- Wraps repeated header values: `map<string, HeaderValues>`
- `Request` / `Response`
- Simple single-message HTTP representation (not used in the streaming tunnel path initially).
- `StreamOpen`
- Opens a new logical stream for HTTP request/response (or other protocols in the future).
- `StreamData`
- Carries body chunks for a stream (`id`, `seq`, `data`).
- `StreamClose`
- Marks the end of a stream (`id`, `error`).
- `StreamAck`
- Legacy ARQ/flow-control hint for UDP/DTLS; in gRPC tunnel it is reserved/optional.
- `Envelope`
- Top-level container with `oneof payload` of the above types.
In the gRPC tunnel, `Envelope` is the only gRPC message type used on the `OpenTunnel` stream.
## 3. Logical Streams and StreamID / 논리 스트림과 StreamID
- A single `OpenTunnel` gRPC stream multiplexes many **logical streams**.
- Each logical stream corresponds to one HTTP request/response pair.
- Logical streams are identified by `StreamOpen.id` (text StreamID).
- The server generates unique IDs per gRPC connection:
- HTTP streams: `"http-{n}"` where `n` is a monotonically increasing counter.
- Control stream: `"control-0"` (special handshake/metadata stream).
Within a gRPC connection:
- Multiple `StreamID`s may be active concurrently.
- Frames with different StreamIDs may be arbitrarily interleaved.
- Order within a stream is tracked by `StreamData.seq` (starting at 0).
## 4. HTTP Request Mapping (Server → Client) / HTTP 요청 매핑
When the public HTTPS reverse-proxy (`cmd/server/main.go`) receives an HTTP request for a domain that is bound
to a client tunnel, it serializes the request into a logical stream as follows.
### 4.1 StreamOpen (request metadata and headers)
- `StreamOpen.id`
- New unique StreamID: `"http-{n}"`.
- `StreamOpen.service_name`
- Logical service selection on the client (e.g., `"web"`).
- `StreamOpen.target_addr`
- Optional explicit local target address on the client (e.g., `"127.0.0.1:8080"`).
- `StreamOpen.header`
- Contains HTTP request headers and pseudo-headers:
- Pseudo-headers:
- `X-HopGate-Method`: HTTP method (e.g., `"GET"`, `"POST"`).
- `X-HopGate-URL`: original URL path + query (e.g., `"/api/v1/foo?bar=1"`).
- `X-HopGate-Host`: Host header value.
- Other keys:
- All remaining HTTP headers from the incoming request, copied as-is into the map.
### 4.2 StreamData* (request body chunks)
- If the request has a body, the server chunks it into fixed-size pieces.
- Chunk size: `protocol.StreamChunkSize` (currently 4 KiB).
- For each chunk:
- `StreamData.id = StreamOpen.id`
- `StreamData.seq` increments from 0, 1, 2, …
- `StreamData.data` contains the raw bytes.
### 4.3 StreamClose (end of request body)
- After sending all body chunks, the server sends a `StreamClose`:
- `StreamClose.id = StreamOpen.id`
- `StreamClose.error` is empty on success.
- If there was an application-level error while reading the body, `error` contains a short description.
The client reconstructs the HTTP request by:
- Reassembling the URL and headers from the `StreamOpen` pseudo-headers and header map.
- Concatenating `StreamData.data` in `seq` order into the request body.
- Treating `StreamClose` as the end-of-stream marker.
## 5. HTTP Response Mapping (Client → Server) / HTTP 응답 매핑
The client receives `StreamOpen` + `StreamData*` + `StreamClose`, performs a local HTTP request to its
configured target (e.g., `http://127.0.0.1:8080`), then returns an HTTP response using the same StreamID.
### 5.1 StreamOpen (response headers and status)
- `StreamOpen.id`
- Same as the request StreamID.
- `StreamOpen.header`
- Contains response headers and a pseudo-header for status:
- Pseudo-header:
- `X-HopGate-Status`: HTTP status code as a string (e.g., `"200"`, `"502"`).
- Other keys:
- All HTTP response headers from the local backend, copied as-is.
### 5.2 StreamData* (response body chunks)
- The client reads the local HTTP response body and chunks it into 4 KiB pieces (same `StreamChunkSize`).
- For each chunk:
- `StreamData.id = StreamOpen.id`
- `StreamData.seq` increments from 0.
- `StreamData.data` contains the raw bytes.
### 5.3 StreamClose (end of response body)
- When the local backend response is fully read, the client sends a `StreamClose`:
- `StreamClose.id` is the same StreamID.
- `StreamClose.error`:
- Empty string on success.
- Short error description if the local HTTP request/response failed (e.g., connect timeout).
The server reconstructs the HTTP response by:
- Parsing `X-HopGate-Status` into an integer HTTP status code.
- Copying other headers into the outgoing response writer (with some security headers overridden by the server).
- Concatenating `StreamData.data` in `seq` order into the HTTP response body.
- Considering `StreamClose.error` for logging/metrics and possibly mapping to error pages if needed.
## 6. Control / Handshake Stream / 컨트롤 스트림
Before any HTTP request streams are opened, the client sends a single **control stream** to authenticate
and describe itself.
- `StreamOpen` (control):
- `id = "control-0"`
- `service_name = "control"`
- `header` contains:
- `X-HopGate-Domain`: domain this client is responsible for.
- `X-HopGate-API-Key`: client API key for the domain.
- `X-HopGate-Local-Target`: default local target such as `127.0.0.1:8080`.
- No `StreamData` is required for the control stream in the initial design.
- The server can optionally reply with its own control `StreamOpen/Close` to signal acceptance/rejection.
On the server side:
- `grpcTunnelServer.OpenTunnel` should:
1. Wait for the first `Envelope` with `StreamOpen.id == "control-0"`.
2. Extract domain, api key, and local target from the headers.
3. Call the ent-based `DomainValidator` to validate `(domain, api_key)`.
4. If validation succeeds, register this gRPC stream as the active tunnel for that domain.
5. If validation fails, log and close the gRPC stream.
Once the control stream handshake completes successfully, the server may start multiplexing multiple
HTTP request streams (`http-0`, `http-1`, …) over the same `OpenTunnel` connection.
## 7. Multiplexing Semantics / 멀티플렉싱 의미
- A single TCP + TLS + HTTP/2 + gRPC connection carries:
- One long-lived `OpenTunnel` gRPC bi-di stream.
- Within it, many logical streams identified by `StreamID`.
- The server can open multiple HTTP streams concurrently for a given client:
- Example: `http-0` for `/css/app.css`, `http-1` for `/api/users`, `http-2` for `/img/logo.png`.
- Frames for these IDs can interleave arbitrarily on the wire.
- Per-stream ordering is preserved by combining `seq` ordering and the reliability of TCP/gRPC.
- Slow or large responses on one stream should not prevent other streams from making progress,
because gRPC/HTTP2 handles stream-level flow control and scheduling.
## 8. Flow Control and StreamAck / 플로우 컨트롤 및 StreamAck
- The gRPC tunnel runs over TCP/HTTP2, which already provides:
- Reliable, in-order delivery.
- Connection-level and stream-level flow control.
- Therefore, application-level selective retransmission is **not required** for the gRPC tunnel.
- `StreamAck` remains defined in the proto for backward compatibility with the DTLS design and
as a potential future hint channel (e.g., window size hints), but is not used in the initial gRPC tunnel.
## 9. Security Considerations / 보안 고려사항
- TLS:
- In production, the server uses ACME-issued certificates, and clients validate the server certificate
using system Root CAs and SNI (`ServerName`).
- In debug mode, clients may use `InsecureSkipVerify: true` to allow local/self-signed certs.
- Authentication:
- Application-level authentication relies on `(domain, api_key)` pairs sent via the control stream headers.
- The server must validate these pairs against the `Domain` table using `DomainValidator`.
- Authorization and isolation:
- Each gRPC tunnel is bound to a single domain (or a defined set of domains) after successful control handshake.
- HTTP requests for other domains must not be forwarded over this tunnel.
이 규약을 기준으로 서버/클라이언트 구현을 정렬하면, 하나의 gRPC `OpenTunnel` 스트림 위에서
여러 HTTP 요청을 안정적으로 멀티플렉싱하면서도, 도메인/API 키 기반 인증과 TLS 보안을 함께 유지할 수 있습니다.