diff --git a/.env.example b/.env.example index 8e90bc7..678202f 100644 --- a/.env.example +++ b/.env.example @@ -41,8 +41,8 @@ HOP_SERVER_HTTP_LISTEN=:8080 # HTTPS 리스닝 포트 (보통 :443) HOP_SERVER_HTTPS_LISTEN=:8443 -# DTLS 리스닝 포트 (보통 :443, 필요시 별도 포트 사용) -HOP_SERVER_DTLS_LISTEN=:8443 +# TLS + yamux 클라이언트 터널 포트 +HOP_SERVER_TUNNEL_LISTEN=:7443 # 메인 도메인 (예: example.com) HOP_SERVER_DOMAIN=example.com @@ -102,9 +102,9 @@ HOP_DB_DSN=postgres://user:pass@localhost:5432/hopgate?sslmode=disable # ---- Client settings ---- -# DTLS 서버 주소 (host:port) -# 예: example.com:443 -HOP_CLIENT_SERVER_ADDR=localhost:8443 +# yamux 터널 서버 주소 (host:port) +# 예: example.com:7443 +HOP_CLIENT_SERVER_ADDR=localhost:7443 # 클라이언트 도메인 HOP_CLIENT_DOMAIN=test.example.com diff --git a/API.md b/API.md index f7f703a..1b72caa 100644 --- a/API.md +++ b/API.md @@ -1,485 +1,58 @@ -# HopGate API Reference / HopGate API 명세 +# HopGate API -This document describes the externally visible APIs currently implemented in HopGate, with English as the primary language and Korean descriptions in parallel. -이 문서는 현재 HopGate에 구현된 외부 공개 API를 정리한 것으로, 영어를 기본으로 하며 한국어 설명을 병기합니다. +## Public Ingress ---- +Registered domains are served by the public listeners configured on the server. +The same request is forwarded to the client's `HOP_CLIENT_LOCAL_TARGET`. -## 1. Admin Plane HTTP API / 관리 Plane HTTP API +| Protocol | Port | Supported behavior | +| --- | --- | --- | +| HTTP/1.1 | TCP `HOP_SERVER_HTTP_LISTEN` / `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Upgrade | +| HTTP/2 | TCP `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Extended CONNECT | +| HTTP/3 | UDP `HOP_SERVER_HTTPS_LISTEN` | HTTP, SSE, WebSocket Extended CONNECT | -The admin plane is exposed under the HTTPS endpoint of the HopGate server. -관리 Plane은 HopGate 서버의 HTTPS 엔드포인트 아래에서 동작합니다. +HTTP/3 is announced to HTTP/1.1 and HTTP/2 clients with `Alt-Svc`. HTTP/2 +Extended CONNECT requires `GODEBUG=http2xconnect=1` when starting the server. -- Base URL: `https://{HOP_SERVER_DOMAIN}/api/v1/admin` - 기본 URL: `https://{HOP_SERVER_DOMAIN}/api/v1/admin` -- Implementation: [`internal/admin/http.go`](internal/admin/http.go) - 구현 위치: [`internal/admin/http.go`](internal/admin/http.go) -- Wired into server main: [`cmd/server/main.go`](cmd/server/main.go) - 서버 메인에서의 연결: [`cmd/server/main.go`](cmd/server/main.go) +### SSE -### 1.1 Authentication / 인증 +SSE is detected when the request `Accept` header contains `text/event-stream`. +The response is streamed without buffering and bypasses the normal proxy +timeout. The upstream should send standard SSE records separated by a blank +line, for example: -- Header: `Authorization: Bearer {HOP_ADMIN_API_KEY}` - 헤더: `Authorization: Bearer {HOP_ADMIN_API_KEY}` -- Env var: `HOP_ADMIN_API_KEY` - 환경 변수: `HOP_ADMIN_API_KEY` -- If the key is missing or incorrect, the API responds with `401 Unauthorized`. - 키가 없거나 값이 올바르지 않으면 `401 Unauthorized` 로 응답합니다. +```text +data: hello -### 1.2 Domain Register API / 도메인 등록 API - -- Method: `POST` - 메서드: `POST` -- Path: `/api/v1/admin/domains/register` - 경로: `/api/v1/admin/domains/register` -- Purpose: Register a new domain and issue a 64-character client API key bound to that domain. - 목적: 새로운 도메인을 등록하고 해당 도메인에 매핑된 64자 클라이언트 API 키를 발급합니다. - -#### 1.2.1 Request / 요청 - -- Content-Type: `application/json` -- Body: - -```json -{ - "domain": "app.example.com", - "memo": "my staging app" -} ``` -- Fields - 필드 +### WebSocket -- `domain` (string, required) - - FQDN, must contain at least one dot, case-insensitive. - - 공백이 없어야 하며, 최소 한 개 이상의 점(`.`)을 포함하는 FQDN이어야 합니다. -- `memo` (string, optional) - - Free-form memo for administrators; may be empty. - - 관리자를 위한 자유 형식 메모로, 비어 있어도 됩니다. +HTTP/1.1 WebSocket uses `Upgrade: websocket`. HTTP/2 and HTTP/3 use Extended +CONNECT with `:protocol=websocket`. The local service may remain an ordinary +HTTP/1.1 WebSocket server; HopGate translates the Extended CONNECT handshake +before relaying the raw bidirectional payload. -#### 1.2.2 Successful Response / 성공 응답 +## Admin API -- Status: `200 OK` -- Body: +Admin endpoints are served under `/api/v1/admin/` on `HOP_SERVER_DOMAIN` and +require `Authorization: Bearer $HOP_ADMIN_API_KEY`. -```json -{ - "success": true, - "client_api_key": "abcd1234...wxyz5678" -} -``` +- `POST /api/v1/admin/domains/register` + - Request: `{"domain":"app.example.com","memo":"optional"}` + - Response includes the generated `client_api_key`. +- `POST /api/v1/admin/domains/unregister` + - Request: `{"domain":"app.example.com","client_api_key":"..."}` -- Fields - 필드 +## Tunnel Configuration -- `success` (boolean) — always `true` on success. - `success` (boolean) — 성공 시 항상 `true` 입니다. -- `client_api_key` (string, length 64) — client API key bound to the registered domain. - `client_api_key` (string, 길이 64) — 등록된 도메인에 매핑된 클라이언트 API 키입니다. +The server listens for client tunnels on `HOP_SERVER_TUNNEL_LISTEN`, defaulting +to `:7443`. The client connects to that address with +`HOP_CLIENT_SERVER_ADDR`. The client only needs an outbound TCP connection. -#### 1.2.3 Error Responses / 에러 응답 +Required client settings are `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, +`HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, and `HOP_CLIENT_DEBUG`. -- `400 Bad Request` - - Invalid JSON body or missing/empty `domain`. - - JSON 바디가 잘못되었거나 `domain` 이 비어 있는 경우. - - Body: - -```json -{ - "success": false, - "error": "invalid request body" -} -``` - - or - -```json -{ - "success": false, - "error": "domain is required" -} -``` - -- `401 Unauthorized` - - Missing or invalid `Authorization` header. - - `Authorization` 헤더가 없거나 잘못된 경우. - - Body: - -```json -{ - "success": false, - "error": "unauthorized" -} -``` - -- `500 Internal Server Error` - - Database or internal logic error while registering domain. - - 도메인 등록 처리 중 데이터베이스 또는 내부 로직 에러가 발생한 경우. - - Body: - -```json -{ - "success": false, - "error": "internal error" -} -``` - -### 1.3 Domain Unregister API / 도메인 해제 API - -- Method: `POST` - 메서드: `POST` -- Path: `/api/v1/admin/domains/unregister` - 경로: `/api/v1/admin/domains/unregister` -- Purpose: Unregister a domain using the `(domain, client_api_key)` pair. - 목적: `(domain, client_api_key)` 조합을 사용해 도메인 등록을 해제합니다. - -#### 1.3.1 Request / 요청 - -- Content-Type: `application/json` -- Body: - -```json -{ - "domain": "app.example.com", - "client_api_key": "abcd1234...wxyz5678" -} -``` - -- Fields - 필드 - -- `domain` (string, required) - - Same normalization rule as the register API (lowercased, trimmed, FQDN-like). - - 등록 API와 동일한 정규화 규칙(소문자, 공백 제거, FQDN 형태)을 따릅니다. -- `client_api_key` (string, required) - - Exact client API key previously issued for the domain. - - 해당 도메인에 대해 이전에 발급된 클라이언트 API 키와 정확히 일치해야 합니다. - -#### 1.3.2 Successful Response / 성공 응답 - -- Status: `200 OK` -- Body: - -```json -{ - "success": true -} -``` - -- `success` (boolean) — `true` if the domain was found and deleted. - `success` (boolean) — 해당 도메인이 존재했고 삭제되었을 때 `true` 입니다. - -#### 1.3.3 Error Responses / 에러 응답 - -- `400 Bad Request` - - Invalid JSON body, or `domain` or `client_api_key` is missing/empty. - - JSON 바디가 잘못되었거나 `domain` 혹은 `client_api_key` 가 비어 있는 경우. - - Body: - -```json -{ - "success": false, - "error": "invalid request body" -} -``` - - or - -```json -{ - "success": false, - "error": "domain and client_api_key are required" -} -``` - -- `401 Unauthorized` - - Missing or invalid `Authorization` header. - - `Authorization` 헤더가 없거나 잘못된 경우. - - Same JSON structure as in the register API. - - JSON 응답 구조는 등록 API와 동일합니다. - -- `500 Internal Server Error` - - Internal error while unregistering or deleting the domain. - - 도메인 해제/삭제 처리 중 내부 에러가 발생한 경우. - - Body: - -```json -{ - "success": false, - "error": "internal error" -} -``` - ---- - -## 2. Public HTTPS Reverse Proxy Entry / 공개 HTTPS 프록시 엔트리 - -HopGate acts as an HTTPS reverse proxy, forwarding incoming HTTP(S) requests for registered domains over DTLS to connected clients. -HopGate는 등록된 도메인에 대한 HTTP(S) 요청을 DTLS를 통해 클라이언트로 전달하는 HTTPS 리버스 프록시 역할을 합니다. - -- Entry points: - 진입점: - - `http://{HOP_SERVER_DOMAIN}/...` - - `https://{HOP_SERVER_DOMAIN}/...` -- Implementation: [`cmd/server/main.go`](cmd/server/main.go) - 구현 위치: [`cmd/server/main.go`](cmd/server/main.go) - -Behavior summary: -동작 요약: - -- If the path starts with `/.well-known/acme-challenge/`, HopGate serves static ACME HTTP-01 challenge files from `HOP_ACME_WEBROOT`. - 경로가 `/.well-known/acme-challenge/` 로 시작하면 HopGate는 `HOP_ACME_WEBROOT` 디렉터리에서 ACME HTTP-01 챌린지 파일을 정적으로 서빙합니다. -- For other paths, HopGate looks up an active DTLS session for the incoming `Host` and forwards the HTTP request over that session. - 그 외 경로에 대해서는 들어온 `Host` 에 해당하는 활성 DTLS 세션을 찾은 뒤, HTTP 요청을 해당 세션을 통해 포워딩합니다. -- If no DTLS session is available for the host, the server responds with `502 Bad Gateway`. - 해당 호스트에 대한 DTLS 세션이 없으면 서버는 `502 Bad Gateway` 로 응답합니다. - -The reverse-proxy behavior is not a separate REST API but the core behavior of the HopGate server. -이 프록시 동작은 별도의 REST API라기보다는 HopGate 서버의 핵심 동작입니다. - ---- - -## 3. DTLS Handshake Protocol / DTLS 핸드셰이크 프로토콜 - -The DTLS handshake between server and client uses a small JSON-based protocol to authenticate the `(domain, client_api_key)` pair before establishing the HTTP tunneling session. -서버와 클라이언트 사이의 DTLS 핸드셰이크는 HTTP 터널링 세션을 열기 전 `(domain, client_api_key)` 조합을 인증하기 위해 간단한 JSON 기반 프로토콜을 사용합니다. - -- Implementation: [`internal/dtls/handshake.go`](internal/dtls/handshake.go) - 구현 위치: [`internal/dtls/handshake.go`](internal/dtls/handshake.go) - -### 3.1 Handshake Request / 핸드셰이크 요청 - -The client sends a JSON message over the DTLS session: -클라이언트는 DTLS 세션 위로 다음과 같은 JSON 메시지를 전송합니다. - -```json -{ - "domain": "app.example.com", - "client_api_key": "abcd1234...wxyz5678" -} -``` - -- `domain` and `client_api_key` must match a registered domain entry for the handshake to succeed. - 핸드셰이크가 성공하려면 `domain` 과 `client_api_key` 가 등록된 도메인 정보와 일치해야 합니다. - -### 3.2 Handshake Response / 핸드셰이크 응답 - -The server responds with: -서버는 다음과 같은 구조로 응답합니다. - -```json -{ - "ok": true, - "message": "handshake ok", - "domain": "app.example.com" -} -``` - -- On failure, `ok` is `false` and `message` contains a human-readable reason (e.g., `"invalid domain or api key"`). - 실패 시 `ok` 는 `false` 이며, `message` 에 `"invalid domain or api key"` 와 같은 사람이 읽을 수 있는 이유가 담깁니다. - -A successful handshake registers the DTLS session for the given domain so that subsequent HTTPS requests for that domain can be tunneled through the session. -핸드셰이크가 성공하면 해당 도메인에 대해 DTLS 세션이 등록되어, 이후 그 도메인으로 들어오는 HTTPS 요청이 이 세션을 통해 터널링될 수 있습니다. - ---- - -## 4. Additional Admin Plane APIs / 추가 관리 Plane API - -This section describes two helper admin APIs for checking whether a domain is registered and retrieving its detailed status. -이 섹션은 도메인 등록 여부를 확인하고 상세 상태를 조회하기 위한 두 가지 관리용 API를 설명합니다. - -Implementation references / 구현 위치: - -- Admin HTTP handlers: [`internal/admin/http.go`](internal/admin/http.go:197) -- Domain service methods: [`internal/admin/service.go`](internal/admin/service.go:129) - -### 4.1 Check Domain Registration (exists) / 도메인 등록 여부 확인 - -- Method / 메서드: `GET` -- Path / 경로: `/api/v1/admin/domains/exists` -- Authentication / 인증: - - Same as other admin APIs: `Authorization: Bearer {HOP_ADMIN_API_KEY}` - 다른 Admin API와 동일하게 `Authorization: Bearer {HOP_ADMIN_API_KEY}` 헤더 사용. -- Purpose / 목적: - - Check if a given domain is already registered in the `Domain` table. - 특정 도메인이 `Domain` 테이블에 이미 등록되어 있는지 확인합니다. - -#### 4.1.1 Request / 요청 - -- Query Parameters / 쿼리 파라미터: - - `domain` (string, required) — domain to check. - `domain` (string, 필수) — 확인할 도메인. - -- Example / 예시: - -```http -GET /api/v1/admin/domains/exists?domain=app.example.com HTTP/1.1 -Host: {HOP_SERVER_DOMAIN} -Authorization: Bearer {HOP_ADMIN_API_KEY} -``` - -#### 4.1.2 Successful Response / 성공 응답 - -- Status: `200 OK` -- Body: - -```json -{ - "success": true, - "exists": true -} -``` - -- Fields / 필드: - - `success` (bool) — request processed successfully. - 요청이 정상 처리되었는지 여부. - - `exists` (bool) — whether the domain is currently registered. - 도메인이 현재 등록되어 있는지 여부. - -If the domain is not registered: -도메인이 등록되어 있지 않으면: - -```json -{ - "success": true, - "exists": false -} -``` - -#### 4.1.3 Error Responses / 에러 응답 - -- `400 Bad Request` - - Missing or empty `domain` query parameter. - `domain` 쿼리 파라미터가 없거나 비어 있는 경우. - -```json -{ - "success": false, - "error": "domain is required" -} -``` - -- `401 Unauthorized` - - Missing or invalid `Authorization` header. - `Authorization` 헤더가 없거나 잘못된 경우. - -```json -{ - "success": false, - "error": "unauthorized" -} -``` - -- `500 Internal Server Error` - - Internal error while checking domain existence (e.g., DB error). - 도메인 존재 여부 확인 중 내부(DB 등) 에러가 발생한 경우. - -```json -{ - "success": false, - "error": "internal error" -} -``` - ---- - -### 4.2 Domain Status API / 도메인 상태 조회 API - -- Method / 메서드: `GET` -- Path / 경로: `/api/v1/admin/domains/status` -- Authentication / 인증: - - `Authorization: Bearer {HOP_ADMIN_API_KEY}` -- Purpose / 목적: - - Retrieve detailed information about a domain if registered, including memo and timestamps. - 도메인이 등록되어 있다면 메모, 생성/수정 시각 등 상세 정보를 조회합니다. - -#### 4.2.1 Request / 요청 - -- Query Parameters / 쿼리 파라미터: - - `domain` (string, required) — domain to inspect. - `domain` (string, 필수) — 조회할 도메인. - -- Example / 예시: - -```http -GET /api/v1/admin/domains/status?domain=app.example.com HTTP/1.1 -Host: {HOP_SERVER_DOMAIN} -Authorization: Bearer {HOP_ADMIN_API_KEY} -``` - -#### 4.2.2 Successful Response (exists) / 성공 응답 (도메인 존재 시) - -- Status: `200 OK` -- Body: - -```json -{ - "success": true, - "exists": true, - "domain": "app.example.com", - "memo": "my staging app", - "created_at": "2025-01-01T12:34:56Z", - "updated_at": "2025-01-02T08:00:00Z" -} -``` - -- Fields / 필드: - - `success` (bool) — request processed successfully. - 요청이 정상 처리되었는지 여부. - - `exists` (bool) — **true** if the domain record exists. - 도메인 레코드가 존재하면 `true`. - - `domain` (string) — normalized domain name. - 정규화된 도메인 이름. - - `memo` (string) — administrator memo. - 관리자 메모. - - `created_at` (string, RFC3339) — creation timestamp. - 생성 시각(RFC3339 문자열). - - `updated_at` (string, RFC3339) — last update timestamp. - 마지막 수정 시각(RFC3339 문자열). - -#### 4.2.3 Successful Response (not exists) / 성공 응답 (도메인 미존재 시) - -If the domain is not found in the database: -해당 도메인이 DB에 존재하지 않으면: - -```json -{ - "success": true, - "exists": false -} -``` - -- No error; this is a normal “not registered” state. - 에러가 아니며, “등록되지 않음” 상태를 의미합니다. - -#### 4.2.4 Error Responses / 에러 응답 - -- `400 Bad Request` - - Missing or empty `domain` query parameter. - -```json -{ - "success": false, - "error": "domain is required" -} -``` - -- `401 Unauthorized` - - Missing or invalid `Authorization` header. - -```json -{ - "success": false, - "error": "unauthorized" -} -``` - -- `500 Internal Server Error` - - Internal error while fetching domain status. - -```json -{ - "success": false, - "error": "internal error" -} -``` +The tunnel is TLS over TCP with yamux multiplexing. The client only needs an +outbound TCP connection to the tunnel listener; public HTTP/3 traffic terminates +at the server and does not require QUIC support in the client. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a09f2d9..beb9d5b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,239 +1,79 @@ -# HopGate Architecture / HopGate 아키텍처 +# HopGate Architecture -이 문서는 HopGate 시스템의 전체 구조를 설명합니다. (ko) -This document describes the overall architecture of the HopGate system. (en) - ---- - -## 1. Overview / 전체 개요 - -- HopGate는 공인 서버와 여러 프라이빗 네트워크 클라이언트 사이에서 HTTP(S) 트래픽을 터널링하는 게이트웨이입니다. (ko) -- HopGate is a gateway that tunnels HTTP(S) traffic between a public server and multiple private-network clients. (en) - -- 서버는 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) - -- 전송 계층은 **TCP + TLS(HTTPS) + HTTP/2 + gRPC** 기반의 터널을 사용해 서버–클라이언트 간 HTTP 요청/응답을 멀티플렉싱합니다. (ko) -- 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) -- An admin plane (REST API) is used to register/unregister domains and issue client API keys. (en) - ---- - -## 2. Directory Layout / 디렉터리 레이아웃 +HopGate exposes public HTTP traffic and forwards it to a private HTTP service +through one outbound TLS connection per client. ```text -. -├── cmd/ -│ ├── server/ # server binary entrypoint -│ └── client/ # client binary entrypoint -├── internal/ -│ ├── config/ # shared configuration loader -│ ├── acme/ # ACME certificate management -│ ├── proxy/ # HTTP proxy / tunneling core (gRPC tunnel) -│ ├── protocol/ # server-client message protocol -│ ├── admin/ # admin plane HTTP handlers -│ └── logging/ # structured logging utilities -├── ent/ -│ └── schema/ # ent schema definitions (e.g. Domain) -└── pkg/ - └── util/ # reusable helpers (optional) + TCP :80/:443 HTTP/1.1, HTTP/2 +public clients -------------------------------> HopGate server + UDP :443 HTTP/3 | + | TLS/TCP + v + yamux logical streams + | + v + HopGate client + | + v + localhost HTTP ``` ---- +## Connection Model -### 2.1 `cmd/` +The client opens one TLS connection to the server and the connection is +multiplexed by yamux. The first logical stream is a bounded JSON control stream +containing the registered domain, local target, and client API key. The server +authenticates this stream before registering the session for the domain. -- [`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 listeners plus the gRPC tunnel endpoint. (en) +Each public request creates one bidirectional yamux stream. Every stream starts +with a bounded JSON `StreamMeta` record and then carries HTTP/1.1 wire data. +The stream kinds currently used are: -- [`cmd/client/main.go`](cmd/client/main.go) — 클라이언트 실행 엔트리 포인트. 설정 로딩, gRPC/HTTP2 터널 연결, 로컬 서비스 프록시 루프를 담당합니다. (ko) -- [`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) +- `control`: client registration and authentication metadata. +- `http`: ordinary HTTP requests and responses. +- `websocket`: HTTP/1.1 Upgrade and HTTP/2/HTTP/3 Extended CONNECT traffic. ---- +Request and response bodies are copied between stream endpoints instead of +being accumulated in memory. Long-lived SSE connections therefore occupy one +yamux stream for their lifetime. -### 2.2 `internal/config` +## Ingress Protocols -- 서버와 클라이언트가 공통으로 사용하는 설정 스키마 및 `.env`/환경 변수 로더를 제공합니다. (ko) -- Provides shared config structs for server and client, plus `.env`/environment variable loaders. (en) +The public server uses one common `http.Handler` for all ingress protocols: -- 주요 구조체 / Main structs: (ko/en) - - `ServerConfig` — HTTP/HTTPS/DTLS 리스닝 주소, 도메인/프록시 도메인, Debug 플래그, 로그 설정. (ko) - - `ServerConfig` — HTTP/HTTPS/DTLS listen addresses, main/proxy domains, debug flag, logging config. (en) - - `ClientConfig` — 서버 주소, 도메인, 클라이언트 API Key, local_target, Debug 플래그, 로그 설정. (ko) - - `ClientConfig` — server address, domain, client API key, local_target, debug flag, logging config. (en) +- HTTP/1.1: ordinary reverse proxy and raw WebSocket Upgrade. +- HTTP/2: ordinary reverse proxy, SSE, and RFC 8441 Extended CONNECT. +- HTTP/3: ordinary reverse proxy, SSE, and RFC 9220 Extended CONNECT. ---- +HTTP/3 runs on a separate UDP listener using `quic-go/http3`, while the TCP +HTTP/HTTPS listeners continue to serve HTTP/1.1 and HTTP/2. HTTP/1.1 and HTTP/2 +responses advertise HTTP/3 with `Alt-Svc`. -### 2.3 `internal/acme` +For HTTP/2 Extended CONNECT, Go's compatibility setting must be enabled when +starting the process: -- ACME(예: Let's Encrypt) 클라이언트 래퍼 및 인증서 매니저를 구현하는 패키지입니다. (ko) -- Package that will wrap an ACME client (e.g. Let's Encrypt) and manage certificates. (en) +```bash +GODEBUG=http2xconnect=1 ./bin/hop-gate-server +``` -- 역할 / Responsibilities: (ko/en) - - 메인 도메인 및 프록시 서브도메인용 TLS 인증서 발급/갱신. (ko) - - Issue/renew TLS certificates for main and proxy domains. (en) - - HTTP-01 / TLS-ALPN-01 챌린지 처리 훅 제공. (ko) - - Provide hooks for HTTP-01 / TLS-ALPN-01 challenges. (en) - - HTTPS 및 gRPC 터널 리스너에 사용할 `*tls.Config` 제공. (ko) - - Provide `*tls.Config` for HTTPS and gRPC tunnel listeners. (en) +## Streaming Policies ---- +Requests accepting `text/event-stream` are treated as SSE. They bypass the +normal request-level proxy timeout, and response writes are flushed to the +public client as they arrive. The client or upstream service is responsible for +closing the SSE request context. -### 2.4 (Reserved for legacy DTLS prototype) +WebSocket Extended CONNECT is translated to a local HTTP/1.1 WebSocket +handshake. After the handshake, the payload is relayed as a bidirectional raw +stream. HTTP/3 Extended CONNECT follows the same application path as HTTP/2. -> 초기 버전에서 DTLS 기반 터널을 실험했으나, 현재 설계에서는 **gRPC/HTTP2 터널만** 사용합니다. -> DTLS 관련 코드는 점진적으로 제거하거나, 별도 브랜치/히스토리에서만 보존할 예정입니다. (ko) -> Early iterations experimented with a DTLS-based tunnel, but the current design uses **gRPC/HTTP2 tunnels only**. -> Any DTLS-related code is planned to be removed or kept only in historical branches. (en) +## Packages ---- +- `internal/tunnel`: TLS dialing, yamux sessions, metadata, and stream lifecycle. +- `cmd/server`: public HTTP/HTTPS/HTTP/3 ingress and yamux tunnel listener. +- `cmd/client`: outbound yamux client and local HTTP/WebSocket forwarding. +- `internal/admin`: domain registration and API-key validation. +- `internal/acme`: certificate acquisition, renewal, and TLS configuration. -### 2.5 `internal/protocol` - -- 서버와 클라이언트가 **gRPC/HTTP2 터널 전송 계층** 위에서 주고받는 HTTP 요청/응답 및 스트림 메시지 포맷을 정의합니다. (ko) -- Defines HTTP request/response and stream message formats exchanged over the gRPC/HTTP2 tunnel transport layer. (en) - -- 요청 메시지 / Request message: (ko/en) - - `RequestID`, `ClientID`, `ServiceName`, `Method`, `URL`, `Header`, `Body`. (ko/en) - -- 응답 메시지 / Response message: (ko/en) - - `RequestID`, `Status`, `Header`, `Body`, `Error`. (ko/en) - -- 스트림 기반 터널링을 위한 Envelope/Stream 타입: (ko/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) - -- 이 구조는 Protobuf 기반 length-prefix 프레이밍을 사용하며, gRPC bi-di stream 의 메시지 타입으로 매핑됩니다. (ko) -- This structure uses protobuf-based length-prefixed framing and is mapped onto messages in a gRPC bi-di stream. (en) - ---- - -### 2.6 `internal/proxy` - -- HTTP Reverse Proxy 및 클라이언트 측 로컬 프록시 코어 로직을 담당합니다. (ko) -- Contains the core logic for the HTTP reverse proxy on the server and the local proxy on the client. (en) - -#### 서버 측 역할 / Server-side role - -- 공인 HTTPS 엔드포인트에서 들어오는 요청을 수신합니다. (ko) -- Receive incoming requests on the public HTTPS endpoint. (en) - -- 도메인/패스 규칙에 따라 적절한 클라이언트와 서비스로 매핑합니다. (ko) -- Map requests to appropriate clients and services based on domain/path rules. (en) - -- 요청/응답을 `internal/protocol` 의 스트림 메시지(`StreamOpen` / `StreamData` / `StreamClose` 등)로 직렬화하여 - 서버–클라이언트 간 gRPC bi-di stream 위에서 주고받습니다. (ko) -- Serialize requests/responses into stream messages from `internal/protocol` (`StreamOpen` / `StreamData` / `StreamClose`, etc.) - and exchange them between server and clients over a gRPC bi-di stream. (en) - -#### 클라이언트 측 역할 / Client-side role - -- 서버가 gRPC 터널을 통해 내려보낸 스트림 메시지를 수신합니다. (ko) -- Receive stream messages sent by the server over the gRPC tunnel. (en) - -- 로컬 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) - -- 응답을 동일한 gRPC bi-di stream 상의 역방향 스트림 메시지로 직렬화하여 서버로 전송합니다. (ko) -- Serialize responses as reverse-direction stream messages on the same gRPC bi-di stream and send them back to the server. (en) - ---- - -### 2.7 `internal/logging` - -- Loki/Grafana 스택에 적합한 구조적 JSON 로깅 인터페이스를 제공합니다. (ko) -- Provides a structured JSON logging interface compatible with the Loki/Grafana stack. (en) - -- 공통 필드 (예: component, request_id, client_id, domain 등)를 포함한 Logger 를 제공합니다. (ko) -- Offers a Logger that includes common fields (e.g., component, request_id, client_id, domain). (en) - ---- - -### 2.8 `ent/schema` - -- `Domain` 등 엔티티에 대한 ent 스키마를 정의합니다. (ko) -- Defines ent schemas for entities such as `Domain`. (en) - -- Domain 엔티티는 다음 정보를 포함합니다: (ko) -- The Domain entity contains the following fields: (en) - - UUID `id` — 기본 키 / primary key. (ko/en) - - `domain` — FQDN (예: app.example.com). (ko/en) - - `client_api_key` — 클라이언트 인증용 64자 키. (ko/en) - - `memo` — 관리자 메모. (ko/en) - - `created_at`, `updated_at` — 감사용 타임스탬프. (ko/en) - ---- - -### 2.9 `pkg/util` (optional) - -- 재사용 가능한 헬퍼 함수/유틸리티를 둘 수 있는 선택적 패키지입니다. (ko) -- Optional package for reusable helpers and utilities. (en) - ---- - -## 3. Request Flow Summary / 요청 흐름 요약 - -1. 외부 사용자가 `https://proxy.example.com/service-a/path` 로 HTTPS 요청을 보냅니다. (ko) -An external user sends an HTTPS request to `https://proxy.example.com/service-a/path`. (en) - -2. HopGate 서버의 HTTPS 리스너가 요청을 수신합니다. (ko) -The HTTPS listener on the HopGate server receives the request. (en) - -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) - -4. 서버는 요청을 `internal/protocol` 의 스트림 메시지(예: `StreamOpen` + 여러 `StreamData` + `StreamClose`)로 직렬화하고, - 선택된 클라이언트와 맺은 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` 레이어는 이 스트림 메시지들을 수신해 로컬 서비스(예: 127.0.0.1:8080)에 HTTP 요청을 수행합니다. (ko) - The client’s `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 응답을 수신하고, 이를 역방향 스트림 메시지(`StreamOpen` + 여러 `StreamData` + `StreamClose`)로 직렬화하여 - 동일한 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. 서버는 응답 스트림 메시지를 조립해 원래의 HTTPS 요청에 대한 HTTP 응답으로 변환한 뒤, 외부 사용자에게 반환합니다. (ko) - 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) - ---- - -## 4. Next Steps / 다음 단계 - -- 위 아키텍처를 기반으로 디렉터리와 엔트리 포인트를 생성/정리합니다. (ko) -- Use this architecture to create/organize directories and entrypoints. (en) - -- `internal/config` 에 필요한 설정 필드와 `.env` 로더를 확장합니다. (ko) -- Extend `internal/config` with required config fields and `.env` loaders. (en) - -- `internal/acme` 에 ACME 클라이언트(certmagic 또는 lego 등)를 연결해 TLS 인증서 발급/갱신을 구현합니다. (ko) -- Wire an ACME client (certmagic, lego, etc.) into `internal/acme` to implement TLS certificate issuance/renewal. (en) - -- gRPC/HTTP2 기반 터널 전송 계층을 설계/구현하고, 서버/클라이언트 모두에서 장기 유지 bi-di stream 위에 - 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 터널링을 구현하고, - gRPC 기반 스트림 모델이 재사용할 수 있는 논리 프로토콜로 정리합니다. (ko) -- Implement real HTTP tunneling and routing rules via `internal/protocol` and `internal/proxy`, - organizing the logical protocol so that the gRPC-based stream model can reuse it. (en) - -- `internal/admin` + `ent` + PostgreSQL 을 사용해 Domain 등록/해제 및 클라이언트 API Key 발급을 완성합니다. (ko) -- Complete domain registration/unregistration and client API key issuing using `internal/admin` + `ent` + PostgreSQL. (en) - -- 로깅/메트릭을 Prometheus + Loki + Grafana 스택과 연동하여 운영 가시성을 확보합니다. (ko) -- Integrate logging/metrics with the Prometheus + Loki + Grafana stack to gain operational visibility. (en) \ No newline at end of file +The tunnel is intentionally stream-oriented. It does not implement application +ACKs or retransmission; TLS over TCP and yamux provide ordered reliable delivery. diff --git a/Dockerfile.server b/Dockerfile.server index dc7792c..c8f777d 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -12,7 +12,7 @@ # hop-gate-server:dev # ---------- Build stage ---------- -FROM golang:1.25-alpine AS builder +FROM golang:1.27-alpine AS builder # BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다. # 기본값을 지정해두면 로컬 docker build 시에도 별도 인자 없이 빌드 가능합니다. @@ -38,7 +38,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-X main.version=${VERSION}" -o /out/hop-gate-server ./cmd/server # ---------- Runtime stage ---------- -FROM alpine:3.20 +FROM alpine:3.24 WORKDIR /app diff --git a/Makefile b/Makefile index 85629c4..49fb797 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,6 @@ docker-server: check-env-server: @if [ -z "$$HOP_SERVER_HTTP_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTP_LISTEN이 설정되지 않았습니다."; exit 1; fi @if [ -z "$$HOP_SERVER_HTTPS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTPS_LISTEN가 설정되지 않았습니다."; exit 1; fi - @if [ -z "$$HOP_SERVER_DTLS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_DTLS_LISTEN가 설정되지 않았습니다."; exit 1; fi @if [ -z "$$HOP_SERVER_DOMAIN" ]; then echo "필수 환경 변수 HOP_SERVER_DOMAIN가 설정되지 않았습니다."; exit 1; fi check-env-client: @@ -84,22 +83,3 @@ check-env-client: @if [ -z "$$HOP_CLIENT_API_KEY" ]; then echo "필수 환경 변수 HOP_CLIENT_API_KEY가 설정되지 않았습니다."; exit 1; fi @if [ -z "$$HOP_CLIENT_LOCAL_TARGET" ]; then echo "필수 환경 변수 HOP_CLIENT_LOCAL_TARGET가 설정되지 않았습니다."; exit 1; fi @if [ -z "$$HOP_CLIENT_DEBUG" ]; then echo "필수 환경 변수 HOP_CLIENT_DEBUG가 설정되지 않았습니다."; exit 1; fi - -# --- Protobuf code generation ------------------------------------------------- -# Requires: -# - protoc (https://grpc.io/docs/protoc-installation/) -# - protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest) -# -# Generates Go types under internal/protocol/pb from internal/protocol/hopgate_stream.proto. -# NOTE: -# - go_package in hopgate_stream.proto is set to: -# github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpb -# - With --go_out=. (without paths=source_relative), protoc will place the -# generated file under internal/protocol/pb according to go_package. -proto: - @echo "Generating Go code from Protobuf schemas..." - protoc \ - --go_out=. \ - internal/protocol/hopgate_stream.proto - @echo "Protobuf generation completed." - diff --git a/README.md b/README.md index 3774a45..0fb39ea 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,22 @@ ## 1. 프로젝트 개요 (Project Overview) -HopGate는 공인 서버와 여러 프라이빗 네트워크 클라이언트 사이에 **DTLS 기반 HTTP 터널**을 제공하는 게이트웨이입니다. -HopGate is a gateway that provides a **DTLS-based HTTP tunnel** between a public server and multiple private-network clients. +HopGate는 공인 서버와 여러 프라이빗 네트워크 클라이언트 사이에 **TLS + yamux 기반 HTTP 터널**을 제공하는 게이트웨이입니다. +HopGate is a gateway that provides a **TLS + yamux HTTP tunnel** between a public server and multiple private-network clients. 주요 특징 (Key features): - 서버는 80/443 포트를 점유하고, ACME(Let's Encrypt 등)로 TLS 인증서를 자동 발급/갱신합니다. The server listens on ports 80/443 and automatically issues/renews TLS certificates via ACME (e.g. Let's Encrypt). -- 서버–클라이언트 간 전송은 DTLS 위에서 이루어지며, 현재는 HTTP 요청/응답을 **Protobuf 기반 length-prefixed Envelope** 로 터널링합니다. - Transport between server and clients uses DTLS; HTTP requests/responses are tunneled as **Protobuf-based, length-prefixed envelopes**. +- 서버–클라이언트 간 기본 전송은 TLS 위의 TCP와 yamux이며, 하나의 연결에 여러 HTTP logical stream을 multiplex합니다. + The default transport is TCP + TLS with yamux multiplexing, carrying multiple HTTP logical streams over one connection. - 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다. An admin management plane (REST API) handles domain registration/unregistration and client API key issuance. - 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다. Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack. -> 참고: 대용량 HTTP 바디에 대해서는 DTLS/UDP MTU 한계 때문에 **단일 Envelope** 로는 한계가 있으므로, `progress.md` 의 3.3A 섹션에 정리된 것처럼 `StreamOpen` / `StreamData` / `StreamClose` 기반의 스트림/프레임 터널링으로 점진적으로 전환할 예정입니다. (ko) -> Note: For very large HTTP bodies, a single-envelope model still hits DTLS/UDP MTU limits. As outlined in section 3.3A of `progress.md`, the plan is to gradually move to a stream/frame-based tunneling model using `StreamOpen` / `StreamData` / `StreamClose`. (en) +> 참고: yamux logical stream은 HTTP/1.1 wire format을 사용하지만, 요청과 응답 body는 버퍼 전체를 메모리에 올리지 않고 스트리밍됩니다. SSE는 연결이 유지되는 동안 이벤트를 즉시 전달합니다. (ko) +> Note: yamux logical streams use HTTP/1.1 wire format, while request and response bodies are streamed without buffering the entire payload in memory. SSE events are delivered while the connection remains open. (en) 아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다. Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md). @@ -31,7 +31,7 @@ Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md). - 서버 엔트리 (Server entrypoint): [`cmd/server/main.go`](cmd/server/main.go) - 클라이언트 엔트리 (Client entrypoint): [`cmd/client/main.go`](cmd/client/main.go) - 설정 로더 (Config loader): [`internal/config/config.go`](internal/config/config.go) -- DTLS 추상/구현 (DTLS abstraction & implementation): [`internal/dtls`](internal/dtls) +- TLS + yamux 터널 (TLS + yamux tunnel): [`internal/tunnel`](internal/tunnel) - 관리 Plane (Admin plane HTTP API): [`internal/admin`](internal/admin) - 도메인 스키마 (Domain schema, ent): [`ent/schema/domain.go`](ent/schema/domain.go) @@ -41,8 +41,8 @@ Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md). ### 3.1 의존성 (Dependencies) -- Go 1.21+ 권장 (go.mod 상 버전보다 최신 Go 사용을 추천) - Go 1.21+ is recommended (even if go.mod specifies an older minor). +- Go 1.27.0+ 필요 + Go 1.27.0 or newer is required. - PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수) PostgreSQL (required for the admin plane and the real DomainValidator). @@ -106,7 +106,7 @@ Required environment variables are validated in two stages: 2. **실행 단계 (Runtime) – 엔트리포인트에서 엄격 검증 (strict runtime validation)** - 서버: [`cmd/server/main.go`](cmd/server/main.go) - - 헬퍼 `getEnvOrPanic(logger, key)` 를 사용해 `HOP_SERVER_HTTP_LISTEN`, `HOP_SERVER_HTTPS_LISTEN`, `HOP_SERVER_DTLS_LISTEN`, `HOP_SERVER_DOMAIN`, `HOP_SERVER_DEBUG` 가 비어 있지 않은지 확인합니다. + - 헬퍼 `getEnvOrPanic(logger, key)` 를 사용해 `HOP_SERVER_HTTP_LISTEN`, `HOP_SERVER_HTTPS_LISTEN`, `HOP_SERVER_DOMAIN`, `HOP_SERVER_DEBUG` 가 비어 있지 않은지 확인합니다. - 누락되었거나 공백인 경우, 구조화 에러 로그(JSON)와 함께 프로세스를 종료합니다. - 클라이언트: [`cmd/client/main.go`](cmd/client/main.go) - `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, `HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, `HOP_CLIENT_DEBUG` 를 동일한 방식으로 검증합니다. @@ -117,41 +117,41 @@ Required environment variables are validated in two stages: 로컬 개발에서는 `.env.example` 을 복사한 `.env` 파일을 사용해 빠르게 설정을 구성할 수 있습니다. For production deployments, prefer OS-level env (Kubernetes `env`, Docker `-e`, systemd `Environment=`, etc.), and use a local `.env` (copied from `.env.example`) mainly for development. -## 4. DTLS 핸드셰이크 테스트 (Testing DTLS Handshake) +## 4. TLS + yamux 터널 설정 (TLS + yamux tunnel configuration) -HopGate는 DTLS 위에서 **도메인 + 클라이언트 API Key** 기반의 애플리케이션 레벨 핸드셰이크를 수행합니다. -HopGate performs an application-level handshake over DTLS using **domain + client API key**. +HopGate는 TLS 연결 위의 yamux control stream에서 **도메인 + 클라이언트 API Key** 기반의 핸드셰이크를 수행합니다. +HopGate authenticates a yamux control stream using **domain + client API key**. ### 4.1 서버 설정 예시 (Server .env example) `.env`: ```env -HOP_SERVER_DTLS_LISTEN=:8443 +HOP_SERVER_TUNNEL_LISTEN=:7443 HOP_SERVER_DEBUG=true ``` -- `HOP_SERVER_DTLS_LISTEN` - DTLS 서버가 바인딩할 UDP 포트입니다. 예: `:8443` - UDP port for the DTLS server to bind on, e.g. `:8443`. +- `HOP_SERVER_TUNNEL_LISTEN` + TLS + yamux 서버가 바인딩할 TCP 포트입니다. 예: `:7443` + TCP port for the TLS + yamux server to bind on, e.g. `:7443`. - `HOP_SERVER_DEBUG=true` - 디버그 모드에서는 [`dtls.NewSelfSignedLocalhostConfig()`](internal/dtls/selfsigned.go) 를 사용해 self-signed localhost 인증서를 생성합니다. - In debug mode the server uses [`dtls.NewSelfSignedLocalhostConfig()`](internal/dtls/selfsigned.go) to generate a self-signed localhost certificate. + 디버그 모드에서는 인증서 검증을 생략할 수 있습니다. 이는 개발 환경에서만 사용해야 합니다. + In debug mode certificate verification may be skipped. Use this only for development. ### 4.2 클라이언트 설정 예시 (Client .env example) `.env`: ```env -HOP_CLIENT_SERVER_ADDR=localhost:8443 +HOP_CLIENT_SERVER_ADDR=localhost:7443 HOP_CLIENT_DOMAIN=test.example.com HOP_CLIENT_API_KEY=TEST_LOCALHOST_API_KEY_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080 HOP_CLIENT_DEBUG=true ``` -- `HOP_CLIENT_SERVER_ADDR` : DTLS 서버 주소 (예: `localhost:8443`) - DTLS server address, e.g. `localhost:8443`. +- `HOP_CLIENT_SERVER_ADDR` : yamux 터널 서버 주소 (예: `localhost:7443`) + yamux tunnel server address, e.g. `localhost:7443`. - `HOP_CLIENT_DOMAIN` / `HOP_CLIENT_API_KEY` : 관리 Plane 에서 발급받은 도메인/키 (실제 ent + PostgreSQL 기반 DomainValidator 에 의해 검증) Domain and API key issued by the admin plane (validated by a real ent + PostgreSQL based DomainValidator). - `HOP_CLIENT_LOCAL_TARGET` : 실제로 HTTP 요청을 보낼 로컬 서버 주소 @@ -169,6 +169,17 @@ HOP_CLIENT_DEBUG=true ./bin/hop-gate-client ``` +HTTP/3 ingress를 사용하려면 서버의 TCP HTTPS 포트와 동일한 UDP 포트를 외부에 노출해야 합니다. +HTTP/3 ingress requires exposing the same port as the HTTPS listener over UDP. + +HTTP/2 Extended CONNECT를 사용하는 클라이언트가 있는 경우 Go HTTP/2의 +호환성 설정을 켜고 서버를 실행합니다. +For HTTP/2 Extended CONNECT clients, enable Go's compatibility setting: + +```bash +GODEBUG=http2xconnect=1 ./bin/hop-gate-server +``` + 성공 시 로그에는 다음과 같은 정보가 찍힙니다. On success, logs will include information like: @@ -210,12 +221,37 @@ For implementation skeleton, see [`internal/admin`](internal/admin) and [`ent/sc - `Debug=true` 설정은 **개발/테스트 용도**입니다. self-signed 인증서 및 InsecureSkipVerify 사용은 프로덕션 환경에서 절대 사용하지 마세요. `Debug=true` is strictly for development/testing. Do not use self-signed certs or InsecureSkipVerify in production. -- 현재 버전은 ACME 기반 인증서, PostgreSQL + ent 기반 DomainValidator, Proxy 레이어가 기본적으로 연동되어 있으나, - 대용량 HTTP 바디에 대해서는 JSON 단일 메시지 기반 터널링 특성상 DTLS/UDP MTU 한계에 부딪힐 수 있습니다. - 스트림/프레임 기반 DTLS 터널링으로의 전환 및 하드닝 작업은 `progress.md` 에 정의된 다음 단계에 포함되어 있습니다. (ko) - The current version wires ACME certificates, a PostgreSQL+ent-based DomainValidator, and the proxy layer by default, - but for very large HTTP bodies the JSON single-message tunneling model can still hit DTLS/UDP MTU limits. - Moving to a stream/frame-based DTLS tunneling model and further hardening are tracked as next steps in `progress.md`. (en) +- 현재 yamux 경로는 HTTP/1.1·HTTP/2·HTTP/3 공개 요청, SSE, HTTP/1.1 WebSocket raw upgrade와 HTTP/2·HTTP/3 Extended CONNECT를 처리합니다. + The yamux path handles public HTTP/1.1, HTTP/2, and HTTP/3 requests, SSE, HTTP/1.1 WebSocket raw upgrade, and HTTP/2 and HTTP/3 Extended CONNECT. + +### Supported Ingress Protocols + +| Ingress | 일반 HTTP | SSE | WebSocket 방식 | +| --- | --- | --- | --- | +| HTTP/1.1 | 지원 | 지원 | HTTP/1.1 Upgrade | +| HTTP/2 | 지원 | 지원 | Extended CONNECT | +| HTTP/3 | 지원 | 지원 | Extended CONNECT | + +모든 ingress는 동일한 TLS + yamux 터널을 통해 클라이언트의 로컬 HTTP 서비스로 전달됩니다. +All ingress protocols use the same TLS + yamux tunnel to reach the client's local HTTP service. + +### SSE and Extended CONNECT WebSocket + +SSE responses are streamed through the yamux stream and do not use the normal +proxy timeout when the request accepts `text/event-stream`. This policy applies +to HTTP/1.1, HTTP/2, and HTTP/3 ingress alike; the client is responsible for +closing the request context when the SSE connection should end. + +HTTP/2 WebSocket Extended CONNECT is enabled by the Go HTTP/2 implementation +with the following process setting: + +```bash +GODEBUG=http2xconnect=1 go run ./cmd/server +``` + +The Extended CONNECT path translates the HTTP/2 or HTTP/3 WebSocket handshake +to the existing local HTTP/1.1 WebSocket connector, then relays the +bidirectional stream through yamux. HopGate는 아직 초기 단계의 실험적 프로젝트입니다. API 및 동작은 언제든지 변경될 수 있습니다. HopGate is still experimental; APIs and behavior may change at any time. diff --git a/cmd/client/main.go b/cmd/client/main.go index a84c8b8..fe782bc 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,47 +1,26 @@ package main import ( - "bytes" "context" - "crypto/tls" - "crypto/x509" "flag" - "fmt" - "io" - "net" - "net/http" - "net/url" "os" - "strconv" "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/logging" - "github.com/dalbodeule/hop-gate/internal/protocol" - protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb" ) -// version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다. -// 기본값 "dev" 는 로컬 개발용입니다. var version = "dev" func getEnvOrPanic(logger logging.Logger, key string) string { value, exists := os.LookupEnv(key) if !exists || strings.TrimSpace(value) == "" { - logger.Error("missing required environment variable", logging.Fields{ - "env": key, - }) + logger.Error("missing required environment variable", logging.Fields{"env": key}) os.Exit(1) } return value } -// maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다. func maskAPIKey(key string) string { if len(key) <= 8 { return "***" @@ -49,747 +28,68 @@ func maskAPIKey(key string) string { return key[:4] + "..." + key[len(key)-4:] } -// firstNonEmpty 는 앞에서부터 처음으로 non-empty 인 문자열을 반환합니다. func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value } } 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() { logger := logging.NewStdJSONLogger("client") - - // 1. 환경변수(.env 포함)에서 클라이언트 설정 로드 - // internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다. envCfg, err := config.LoadClientConfigFromEnv() if err != nil { - logger.Error("failed to load client config from env", logging.Fields{ - "error": err.Error(), - }) + logger.Error("failed to load client config from env", logging.Fields{"error": err.Error()}) os.Exit(1) } - // 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선) serverAddrEnv := getEnvOrPanic(logger, "HOP_CLIENT_SERVER_ADDR") - clientDomainEnv := getEnvOrPanic(logger, "HOP_CLIENT_DOMAIN") + domainEnv := getEnvOrPanic(logger, "HOP_CLIENT_DOMAIN") apiKeyEnv := getEnvOrPanic(logger, "HOP_CLIENT_API_KEY") localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET") debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG") - - // 디버깅 플래그 형식 확인 if debugEnv != "true" && debugEnv != "false" { - logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{ - "env": "HOP_CLIENT_DEBUG", - "value": debugEnv, - }) + logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{"value": debugEnv}) os.Exit(1) } - // 유효성 검사 결과를 구조화 로그로 출력 - logger.Info("validated client env vars", logging.Fields{ - "HOP_CLIENT_SERVER_ADDR": serverAddrEnv, - "HOP_CLIENT_DOMAIN": clientDomainEnv, - "HOP_CLIENT_API_KEY_MASK": maskAPIKey(apiKeyEnv), - "HOP_CLIENT_LOCAL_TARGET": localTargetEnv, - "HOP_CLIENT_DEBUG": debugEnv, - }) - - // CLI 인자 정의 (env 보다 우선 적용됨) - serverAddrFlag := flag.String("server-addr", "", "HopGate server address (host:port)") - domainFlag := flag.String("domain", "", "registered domain (e.g. api.example.com)") - 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") - + serverAddrFlag := flag.String("server-addr", "", "HopGate yamux server address (host:port)") + domainFlag := flag.String("domain", "", "registered domain") + apiKeyFlag := flag.String("api-key", "", "client API key for the domain") + localTargetFlag := flag.String("local-target", "", "local HTTP target (host:port)") flag.Parse() - // 2. CLI 인자 우선, env 후순위로 최종 설정 구성 finalCfg := &config.ClientConfig{ - ServerAddr: firstNonEmpty(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(envCfg.ServerAddr)), - Domain: firstNonEmpty(strings.TrimSpace(*domainFlag), strings.TrimSpace(envCfg.Domain)), - ClientAPIKey: firstNonEmpty(strings.TrimSpace(*apiKeyFlag), strings.TrimSpace(envCfg.ClientAPIKey)), - LocalTarget: firstNonEmpty(strings.TrimSpace(*localTargetFlag), strings.TrimSpace(envCfg.LocalTarget)), + ServerAddr: firstNonEmpty(*serverAddrFlag, envCfg.ServerAddr), + Domain: firstNonEmpty(*domainFlag, envCfg.Domain), + ClientAPIKey: firstNonEmpty(*apiKeyFlag, envCfg.ClientAPIKey), + LocalTarget: firstNonEmpty(*localTargetFlag, envCfg.LocalTarget), Debug: envCfg.Debug, Logging: envCfg.Logging, } - - // 3. 필수 필드 검증 - missing := []string{} - if finalCfg.ServerAddr == "" { - missing = append(missing, "server_addr") - } - if finalCfg.Domain == "" { - missing = append(missing, "domain") - } - if finalCfg.ClientAPIKey == "" { - missing = append(missing, "api_key") - } - if finalCfg.LocalTarget == "" { - missing = append(missing, "local_target") - } - - if len(missing) > 0 { - logger.Error("client config missing required fields", logging.Fields{ - "missing": missing, + if finalCfg.ServerAddr == "" || finalCfg.Domain == "" || finalCfg.ClientAPIKey == "" || finalCfg.LocalTarget == "" { + logger.Error("client config is incomplete", logging.Fields{ + "server_addr": finalCfg.ServerAddr != "", + "domain": finalCfg.Domain != "", + "api_key": finalCfg.ClientAPIKey != "", + "local_target": finalCfg.LocalTarget != "", }) os.Exit(1) } - logger.Info("hop-gate client starting", logging.Fields{ - "stack": "prometheus-loki-grafana", - "version": version, - "server_addr": finalCfg.ServerAddr, - "domain": finalCfg.Domain, - "local_target": finalCfg.LocalTarget, - "client_api_key_masked": maskAPIKey(finalCfg.ClientAPIKey), - "debug": finalCfg.Debug, + logger.Info("hop-gate yamux client starting", logging.Fields{ + "version": version, + "server_addr": serverAddrEnv, + "domain": domainEnv, + "client_api_key_mask": maskAPIKey(apiKeyEnv), + "local_target": localTargetEnv, + "debug": finalCfg.Debug, }) - ctx := context.Background() - - // 현재 클라이언트는 DTLS 레이어 없이 gRPC 터널만을 사용합니다. (ko) - // The client now uses only the gRPC tunnel, without any DTLS layer. (en) - if err := runGRPCTunnelClient(ctx, logger, finalCfg); err != nil { - logger.Error("grpc tunnel client exited with error", logging.Fields{ - "error": err.Error(), - }) + if err := runYamuxTunnelClient(context.Background(), logger, finalCfg); err != nil { + logger.Error("yamux tunnel client exited with error", logging.Fields{"error": err.Error()}) os.Exit(1) } - - logger.Info("grpc tunnel client exited normally", nil) } diff --git a/cmd/client/yamux_tunnel.go b/cmd/client/yamux_tunnel.go new file mode 100644 index 0000000..d2061fe --- /dev/null +++ b/cmd/client/yamux_tunnel.go @@ -0,0 +1,177 @@ +package main + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dalbodeule/hop-gate/internal/config" + "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/tunnel" + "github.com/gorilla/websocket" +) + +func runYamuxTunnelClient(ctx context.Context, logger logging.Logger, cfg *config.ClientConfig) error { + host := cfg.ServerAddr + if h, _, err := net.SplitHostPort(cfg.ServerAddr); err == nil { + host = h + } + tlsConfig := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} + if cfg.Debug { + tlsConfig.InsecureSkipVerify = true + } else if roots, err := x509.SystemCertPool(); err == nil { + tlsConfig.RootCAs = roots + } + + session, err := tunnel.DialTLS(ctx, cfg.ServerAddr, tlsConfig) + if err != nil { + return err + } + defer session.Close() + + control, err := session.Open(ctx, tunnel.StreamMeta{ + Kind: "control", + Domain: cfg.Domain, + Target: cfg.LocalTarget, + Headers: map[string][]string{ + "X-HopGate-API-Key": {cfg.ClientAPIKey}, + }, + }) + if err != nil { + return fmt.Errorf("open yamux control stream: %w", err) + } + _ = control.Close() + + localBase, err := url.Parse("http://" + cfg.LocalTarget) + if err != nil { + return fmt.Errorf("parse local target: %w", err) + } + client := &http.Client{Timeout: 0, Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + }} + logger.Info("yamux tunnel client connected", logging.Fields{"server_addr": cfg.ServerAddr, "domain": cfg.Domain}) + + for { + stream, err := session.Accept(ctx) + if err != nil { + return err + } + go handleYamuxHTTPStream(ctx, stream, client, localBase, logger) + } +} + +func handleYamuxHTTPStream(ctx context.Context, stream *tunnel.Stream, client *http.Client, localBase *url.URL, logger logging.Logger) { + defer stream.Close() + if stream.Meta.Kind == "websocket" { + handleYamuxWebSocketStream(ctx, stream, localBase, logger) + return + } + if stream.Meta.Kind != "http" { + logger.Warn("unsupported yamux stream kind", logging.Fields{"kind": stream.Meta.Kind}) + return + } + request, err := http.ReadRequest(bufio.NewReader(stream)) + if err != nil { + logger.Warn("read HTTP request from yamux stream failed", logging.Fields{"error": err.Error()}) + return + } + request.URL.Scheme = localBase.Scheme + request.URL.Host = localBase.Host + request.RequestURI = "" + response, err := client.Do(request) + if err != nil { + logger.Warn("forward HTTP request to local target failed", logging.Fields{"error": err.Error()}) + failure := &http.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}, + Body: http.NoBody, + Request: request, + } + if writeErr := failure.Write(stream); writeErr != nil { + logger.Warn("write local HTTP failure to yamux stream failed", logging.Fields{"error": writeErr.Error()}) + } + return + } + defer response.Body.Close() + if err := writeHTTPResponse(stream, response); err != nil { + logger.Warn("write local HTTP response to yamux stream failed", logging.Fields{"error": err.Error()}) + } +} + +func writeHTTPResponse(stream io.Writer, response *http.Response) error { + return response.Write(stream) +} + +func handleYamuxWebSocketStream(ctx context.Context, stream *tunnel.Stream, localBase *url.URL, logger logging.Logger) { + request, err := http.ReadRequest(bufio.NewReader(stream)) + if err != nil { + logger.Warn("read WebSocket request from yamux stream failed", logging.Fields{"error": err.Error()}) + return + } + request.URL.Scheme = "ws" + request.URL.Host = localBase.Host + request.RequestURI = "" + + header := make(http.Header) + var subprotocols []string + for key, values := range request.Header { + switch http.CanonicalHeaderKey(key) { + case "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions": + continue + case "Sec-Websocket-Protocol": + for _, value := range values { + for _, protocol := range strings.Split(value, ",") { + if strings.TrimSpace(protocol) != "" { + subprotocols = append(subprotocols, strings.TrimSpace(protocol)) + } + } + } + default: + header[key] = append([]string(nil), values...) + } + } + dialer := websocket.Dialer{Subprotocols: subprotocols, HandshakeTimeout: 10 * time.Second} + backend, response, err := dialer.DialContext(ctx, request.URL.String(), header) + if err != nil { + logger.Warn("dial local WebSocket failed", logging.Fields{"error": err.Error()}) + failure := &http.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}, + Body: http.NoBody, + } + _ = failure.Write(stream) + return + } + defer backend.Close() + if err := response.Write(stream); err != nil { + logger.Warn("write WebSocket handshake to server failed", logging.Fields{"error": err.Error()}) + return + } + + backendConn := backend.UnderlyingConn() + result := make(chan error, 2) + go func() { + _, err := io.Copy(stream, backendConn) + result <- err + }() + go func() { + _, err := io.Copy(backendConn, stream) + result <- err + }() + <-result +} diff --git a/cmd/server/main.go b/cmd/server/main.go index bb74c30..d119ecd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,9 +2,7 @@ package main import ( "bufio" - "bytes" "context" - "crypto/tls" "fmt" "io" stdfs "io/fs" @@ -12,1177 +10,48 @@ import ( "net/http" "os" "path/filepath" - "sort" "strconv" "strings" "sync" "time" "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/quic-go/quic-go/http3" "github.com/dalbodeule/hop-gate/internal/acme" "github.com/dalbodeule/hop-gate/internal/admin" "github.com/dalbodeule/hop-gate/internal/config" - "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/errorpages" "github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/observability" - "github.com/dalbodeule/hop-gate/internal/protocol" - protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb" "github.com/dalbodeule/hop-gate/internal/store" ) -// version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다. -// 기본값 "dev" 는 로컬 개발용입니다. var version = "dev" -// pendingRequest tracks a request waiting for its response -type pendingRequest struct { - streamID protocol.StreamID - respCh chan *protocol.Envelope - doneCh chan struct{} -} - -// streamSender 는 특정 스트림에 대해 전송한 StreamData 프레임의 payload 를 -// 시퀀스 번호별로 보관하여, peer 로부터의 StreamAck 를 기반으로 선택적 재전송을 -// 수행하기 위한 송신 측 ARQ 상태를 나타냅니다. (ko) -// streamSender keeps outstanding StreamData payloads per sequence number so that -// they can be selectively retransmitted based on StreamAck from the peer. (en) -type streamSender struct { - mu sync.Mutex - outstanding map[uint64][]byte -} - -func newStreamSender() *streamSender { - return &streamSender{ - outstanding: make(map[uint64][]byte), - } -} - -func (s *streamSender) register(seq uint64, data []byte) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.outstanding == nil { - s.outstanding = make(map[uint64][]byte) - } - buf := make([]byte, len(data)) - copy(buf, data) - s.outstanding[seq] = buf -} - -// handleAck 는 주어진 StreamAck 를 적용하여 AckSeq 이하의 프레임을 정리하고, -// LostSeqs 중 아직 outstanding 에 남아 있는 시퀀스의 payload 를 복사하여 -// 재전송 대상 목록으로 반환합니다. (ko) -// handleAck applies the given StreamAck, removes frames up to AckSeq, and -// returns copies of payloads for LostSeqs that are still outstanding so that -// they can be retransmitted. (en) -func (s *streamSender) handleAck(ack *protocol.StreamAck) map[uint64][]byte { - s.mu.Lock() - defer s.mu.Unlock() - - if s.outstanding == nil { - return nil - } - - // 연속 수신 완료 구간(seq <= AckSeq)은 outstanding 에서 제거합니다. - for seq := range s.outstanding { - if seq <= ack.AckSeq { - delete(s.outstanding, seq) - } - } - - // LostSeqs 가 비어 있으면 재전송할 것이 없습니다. - if len(ack.LostSeqs) == 0 { - return nil - } - - // LostSeqs 중 아직 outstanding 에 남아 있는 것만 재전송 대상으로 선택합니다. - lost := make(map[uint64][]byte, len(ack.LostSeqs)) - for _, seq := range ack.LostSeqs { - if data, ok := s.outstanding[seq]; ok { - buf := make([]byte, len(data)) - copy(buf, data) - lost[seq] = buf - } - } - return lost -} - -type dtlsSessionWrapper struct { - sess dtls.Session - bufferedReader *bufio.Reader - codec protocol.WireCodec - logger logging.Logger - - mu sync.Mutex - nextStreamID uint64 - pending map[protocol.StreamID]*pendingRequest - readerDone chan struct{} - - // streamSenders 는 서버 → 클라이언트 방향 HTTP 요청 바디 전송에 대한 - // 송신 측 ARQ 상태를 보관합니다. (ko) - // streamSenders keeps ARQ sender state for HTTP request bodies sent - // from server to client. (en) - streamSenders map[protocol.StreamID]*streamSender -} - -// registerStreamSender 는 주어진 스트림 ID 에 대한 송신 측 ARQ 상태를 등록합니다. (ko) -// registerStreamSender registers the sender-side ARQ state for a given stream ID. (en) -func (w *dtlsSessionWrapper) registerStreamSender(id protocol.StreamID, sender *streamSender) { - w.mu.Lock() - defer w.mu.Unlock() - - if w.streamSenders == nil { - w.streamSenders = make(map[protocol.StreamID]*streamSender) - } - w.streamSenders[id] = sender -} - -// unregisterStreamSender 는 더 이상 사용하지 않는 스트림 ID 에 대한 송신 측 ARQ 상태를 제거합니다. (ko) -// unregisterStreamSender removes the sender-side ARQ state for a stream ID that is no longer used. (en) -func (w *dtlsSessionWrapper) unregisterStreamSender(id protocol.StreamID) { - w.mu.Lock() - defer w.mu.Unlock() - - if w.streamSenders == nil { - return - } - delete(w.streamSenders, id) -} - -// getStreamSender 는 주어진 스트림 ID 에 대한 송신 측 ARQ 상태를 반환합니다. (ko) -// getStreamSender returns the sender-side ARQ state for the given stream ID, if any. (en) -func (w *dtlsSessionWrapper) getStreamSender(id protocol.StreamID) *streamSender { - w.mu.Lock() - defer w.mu.Unlock() - - if w.streamSenders == nil { - return nil - } - return w.streamSenders[id] -} - func getEnvOrPanic(logger logging.Logger, key string) string { value, exists := os.LookupEnv(key) if !exists || strings.TrimSpace(value) == "" { - logger.Error("missing required environment variable", logging.Fields{ - "env": key, - }) + logger.Error("missing required environment variable", logging.Fields{"env": key}) os.Exit(1) } return value } -// canonicalizeDomainForDNS 는 DTLS 핸드셰이크에서 전달된 도메인 문자열을 -// DNS 조회 및 DB 조회에 사용할 수 있는 정규화된 호스트명으로 변환합니다. (ko) -// canonicalizeDomainForDNS normalizes the domain string from the DTLS handshake -// into a host name suitable for DNS and DB lookups. (en) -func canonicalizeDomainForDNS(raw string) string { - d := strings.TrimSpace(raw) - if d == "" { - return "" - } - // "host:port" 형태가 들어온 경우 포트를 제거합니다. (ko) - // Strip port if the value is in "host:port" form. (en) - if h, _, err := net.SplitHostPort(d); err == nil && strings.TrimSpace(h) != "" { - d = h - } - return strings.ToLower(d) -} - -// domainGateValidator 는 DTLS 핸드셰이크 시 도메인이 EXPECT_IPS(HOP_ACME_EXPECT_IPS)에 -// 설정된 IP(IPv4/IPv6)로 해석되는지 검사한 뒤, 내부 DomainValidator 로 위임합니다. (ko) -// domainGateValidator first checks that the domain resolves to one of the -// expected IPs (from HOP_ACME_EXPECT_IPS), then delegates to the inner -// DomainValidator for (domain, client_api_key) validation. (en) -type domainGateValidator struct { - expectedIPs []net.IP - inner dtls.DomainValidator - logger logging.Logger -} - -func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error { - d := canonicalizeDomainForDNS(domain) - if d == "" { - return fmt.Errorf("empty domain is not allowed for dtls handshake") - } - - // EXPECT_IPS(HOP_ACME_EXPECT_IPS)가 설정된 경우, 도메인이 해당 IP(IPv4/IPv6)들로 - // 해석되는지 DNS(A/AAAA) 조회를 통해 검증합니다. (ko) - // If EXPECT_IPS (HOP_ACME_EXPECT_IPS) is configured, ensure that the domain - // resolves (via A/AAAA) to at least one of the expected IPs. (en) - if len(v.expectedIPs) > 0 { - resolver := net.DefaultResolver - if ctx == nil { - ctx = context.Background() - } - ips, err := resolver.LookupIP(ctx, "ip", d) - if err != nil { - if v.logger != nil { - v.logger.Warn("dtls handshake dns resolution failed", logging.Fields{ - "domain": d, - "error": err.Error(), - }) - } - return fmt.Errorf("dns resolution failed for %s: %w", d, err) - } - - match := false - for _, ip := range ips { - for _, expected := range v.expectedIPs { - if ip.Equal(expected) { - match = true - break - } - } - if match { - break - } - } - - if !match { - if v.logger != nil { - v.logger.Warn("dtls handshake rejected due to unexpected resolved IPs", logging.Fields{ - "domain": d, - "resolved_ips": ips, - "expected_ips": v.expectedIPs, - }) - } - return fmt.Errorf("domain %s does not resolve to any expected IPs", d) - } - } - - if v.inner != nil { - return v.inner.ValidateDomainAPIKey(ctx, d, clientAPIKey) - } - return nil -} - -// parseExpectedIPsFromEnv 는 HOP_ACME_EXPECT_IPS 와 같이 콤마로 구분된 IP 목록 -// 환경변수를 파싱해 net.IP 슬라이스로 변환합니다. IPv4/IPv6 모두 지원합니다. (ko) -// parseExpectedIPsFromEnv parses a comma-separated list of IPs from env (e.g. HOP_ACME_EXPECT_IPS) -// into a slice of net.IP, supporting both IPv4 and IPv6 literals. (en) -func parseExpectedIPsFromEnv(logger logging.Logger, envKey string) []net.IP { - raw := strings.TrimSpace(os.Getenv(envKey)) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - var result []net.IP - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - ip := net.ParseIP(p) - if ip == nil { - if logger != nil { - logger.Warn("invalid ip in env, skipping", logging.Fields{ - "env": envKey, - "value": p, - }) - } - continue - } - result = append(result, ip) - } - if logger != nil { - logger.Info("loaded expected handshake ips from env", logging.Fields{ - "env": envKey, - "ips": result, - }) - } - return result -} - -// ForwardHTTP 는 HTTP 요청을 DTLS 세션 위의 StreamOpen/StreamData/StreamClose 프레임으로 전송하고, -// 역방향 스트림 응답을 수신해 protocol.Response 로 반환합니다. (ko) -// readLoop continuously reads from the DTLS session and dispatches incoming frames -// to the appropriate pending request based on stream ID. It also handles -// application-level ARQ (StreamAck) for request bodies sent from server to client. (en) -func (w *dtlsSessionWrapper) readLoop() { - defer close(w.readerDone) - - for { - var env protocol.Envelope - if err := w.codec.Decode(w.bufferedReader, &env); err != nil { - if err == io.EOF { - w.logger.Info("dtls session closed", nil) - } else { - w.logger.Error("failed to decode envelope in read loop", logging.Fields{ - "error": err.Error(), - }) - } - // Notify all pending requests of the error by closing their response channels. - // The doneCh will be closed by each ForwardHTTP's defer. - w.mu.Lock() - for _, pending := range w.pending { - close(pending.respCh) - } - w.pending = make(map[protocol.StreamID]*pendingRequest) - w.mu.Unlock() - return - } - - // 1) StreamAck 처리: 서버 → 클라이언트 방향 요청 바디 전송에 대한 ARQ. (ko) - // 1) Handle StreamAck: application-level ARQ for request bodies - // sent from server to client. (en) - if env.Type == protocol.MessageTypeStreamAck { - sa := env.StreamAck - if sa == nil { - w.logger.Warn("received stream_ack envelope with nil payload", logging.Fields{}) - continue - } - streamID := sa.ID - sender := w.getStreamSender(streamID) - if sender == nil { - w.logger.Warn("received stream_ack for unknown stream ID", logging.Fields{ - "stream_id": streamID, - }) - continue - } - lost := sender.handleAck(sa) - for seq, data := range lost { - retryEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamData, - StreamData: &protocol.StreamData{ - ID: streamID, - Seq: seq, - Data: data, - }, - } - if err := w.codec.Encode(w.sess, &retryEnv); err != nil { - w.logger.Error("failed to retransmit stream_data after stream_ack", logging.Fields{ - "stream_id": streamID, - "seq": seq, - "error": err.Error(), - }) - // 세션 쓰기 오류가 발생하면 루프를 종료하여 상위에서 세션 종료를 유도합니다. (ko) - // On write error, stop the loop so that the caller can tear down the session. (en) - return - } - } - // StreamAck 는 애플리케이션 페이로드를 포함하지 않으므로 pending 에 전달하지 않습니다. (ko) - // StreamAck carries no application payload, so it is not forwarded to pending requests. (en) - continue - } - - // 2) StreamOpen / StreamData / StreamClose 에 대해 stream ID 를 산출하고, - // 해당 pending 요청으로 전달합니다. (ko) - // 2) For StreamOpen / StreamData / StreamClose, determine the stream ID - // and forward to the corresponding pending request. (en) - var streamID protocol.StreamID - switch env.Type { - case protocol.MessageTypeStreamOpen: - if env.StreamOpen != nil { - streamID = env.StreamOpen.ID - } - case protocol.MessageTypeStreamData: - if env.StreamData != nil { - streamID = env.StreamData.ID - } - case protocol.MessageTypeStreamClose: - if env.StreamClose != nil { - streamID = env.StreamClose.ID - } - default: - w.logger.Warn("received unexpected envelope type in read loop", logging.Fields{ - "type": env.Type, - }) - continue - } - - if streamID == "" { - w.logger.Warn("received envelope with empty stream ID", logging.Fields{ - "type": env.Type, - }) - continue - } - - // Find the pending request for this stream ID - w.mu.Lock() - pending := w.pending[streamID] - w.mu.Unlock() - - if pending == nil { - w.logger.Warn("received envelope for unknown stream ID", logging.Fields{ - "stream_id": streamID, - "type": env.Type, - }) - continue - } - - // Send the envelope to the waiting request - select { - case pending.respCh <- &env: - // Successfully delivered - case <-pending.doneCh: - // Request was cancelled or timed out - w.logger.Warn("pending request already closed", logging.Fields{ - "stream_id": streamID, - }) - default: - // Channel buffer full - shouldn't happen with proper sizing - w.logger.Warn("response channel buffer full, dropping frame", logging.Fields{ - "stream_id": streamID, - "type": env.Type, - }) - } - } -} - -// ForwardHTTP forwards an HTTP request over the DTLS session using StreamOpen/StreamData/StreamClose -// frames and reconstructs the reverse stream into a protocol.Response. (en) -// This method now supports concurrent requests by using a channel-based multiplexing approach. -func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) { - if ctx == nil { - ctx = context.Background() - } - - // 클라이언트는 단일 DTLS 세션 내에서 다중 HTTP 스트림을 처리할 수 있도록 - // 중앙 readLoop + per-stream demux 구조(3.3B.1~3.3B.2)가 적용되어 있습니다. (ko) - // With the client-side central read loop + per-stream demux (3.3B.1–3.3B.2), - // a single DTLS session can now handle multiple concurrent HTTP streams. (en) - // - // 3.3B.4에서 정의한 것처럼, 서버 측에서는 더 이상 세션 단위 직렬화 락을 사용하지 않고 - // 동일 DTLS 세션 위에서 여러 ForwardHTTP 호출이 서로 다른 StreamID 로 병렬 진행되도록 - // 허용합니다. (ko) - // As per 3.3B.4, we no longer use a session-level serialization lock here and - // allow multiple ForwardHTTP calls to run concurrently on the same DTLS session - // using distinct StreamIDs. (en) - - // Generate a unique stream ID (needs mutex for nextStreamID) - w.mu.Lock() - streamID := w.nextHTTPStreamID() - - // Channel buffer size for response frames to avoid blocking readLoop. - // A typical HTTP response has: 1 StreamOpen + N StreamData + 1 StreamClose frames. - // With 4KB chunks, even large responses stay within this buffer. - const responseChannelBuffer = 16 - - // Create a pending request to receive responses - pending := &pendingRequest{ - streamID: streamID, - respCh: make(chan *protocol.Envelope, responseChannelBuffer), - doneCh: make(chan struct{}), - } - w.pending[streamID] = pending - w.mu.Unlock() - - // 서버 → 클라이언트 방향 요청 바디 전송에 대한 송신 측 ARQ 상태를 준비합니다. (ko) - // Prepare ARQ sender state for the request body sent from server to client. (en) - sender := newStreamSender() - w.registerStreamSender(streamID, sender) - - // Ensure cleanup on exit - defer func() { - w.mu.Lock() - delete(w.pending, streamID) - w.mu.Unlock() - close(pending.doneCh) - w.unregisterStreamSender(streamID) - }() - - log := logger.With(logging.Fields{ - "component": "http_to_dtls", - "request_id": string(streamID), - "method": req.Method, - "url": req.URL.String(), - }) - - log.Info("forwarding http request over dtls (stream mode)", logging.Fields{ - "host": req.Host, - "scheme": req.URL.Scheme, - }) - - // 요청 헤더를 복사하고 pseudo-header 로 HTTP 메타데이터를 추가합니다. (ko) - // Copy request headers and attach HTTP metadata as pseudo-headers. (en) - hdr := make(map[string][]string, len(req.Header)+3) - for k, vs := range req.Header { - hdr[k] = append([]string(nil), vs...) - } - hdr[protocol.HeaderKeyMethod] = []string{req.Method} - if req.URL != nil { - hdr[protocol.HeaderKeyURL] = []string{req.URL.String()} - } - host := req.Host - if host == "" && req.URL != nil { - host = req.URL.Host - } - if host != "" { - hdr[protocol.HeaderKeyHost] = []string{host} - } - - // StreamOpen 전송: 어떤 서비스로 라우팅해야 하는지와 초기 헤더를 전달합니다. (ko) - // Send StreamOpen to indicate which service to route to and initial headers. (en) - openEnv := &protocol.Envelope{ - Type: protocol.MessageTypeStreamOpen, - StreamOpen: &protocol.StreamOpen{ - ID: streamID, - Service: serviceName, - TargetAddr: "", - Header: hdr, - }, - } - if err := w.codec.Encode(w.sess, openEnv); err != nil { - log.Error("failed to encode stream_open envelope", logging.Fields{ - "error": err.Error(), - }) - return nil, err - } - - // 요청 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko) - // Chunk the request body into 4KiB (StreamChunkSize) StreamData frames. (en) - 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]...) - // 송신 측 ARQ: Seq 별 payload 를 기록해 두었다가, 클라이언트의 StreamAck 를 기반으로 재전송합니다. (ko) - // Sender-side ARQ: record payload per Seq so it can be retransmitted based on StreamAck from the client. (en) - sender.register(seq, dataCopy) - - dataEnv := &protocol.Envelope{ - Type: protocol.MessageTypeStreamData, - StreamData: &protocol.StreamData{ - ID: streamID, - Seq: seq, - Data: dataCopy, - }, - } - if err2 := w.codec.Encode(w.sess, dataEnv); err2 != nil { - log.Error("failed to encode stream_data envelope", 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) - } - } - } - - // 바디 종료를 알리는 StreamClose 를 전송합니다. (ko) - // Send StreamClose to mark the end of the request body. (en) - closeReqEnv := &protocol.Envelope{ - Type: protocol.MessageTypeStreamClose, - StreamClose: &protocol.StreamClose{ - ID: streamID, - Error: "", - }, - } - if err := w.codec.Encode(w.sess, closeReqEnv); err != nil { - log.Error("failed to encode request stream_close envelope", logging.Fields{ - "error": err.Error(), - }) - return nil, err - } - - // 클라이언트로부터 역방향 스트림 응답을 수신합니다. (ko) - // Receive reverse stream response (StreamOpen + StreamData* + StreamClose) via the readLoop. (en) - var ( - resp protocol.Response - bodyBuf bytes.Buffer - gotOpen bool - statusCode = http.StatusOK - - // 응답 바디(클라이언트 → 서버)에 대한 수신 측 ARQ 상태입니다. (ko) - // ARQ receiver state for the response body (client → server). (en) - expectedSeq uint64 - received = make(map[uint64][]byte) - lost = make(map[uint64]struct{}) - ) - const maxLostReport = 32 - - resp.RequestID = string(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 <-w.readerDone: - log.Error("dtls session closed while waiting for response", nil) - return nil, fmt.Errorf("dtls session closed") - - case env, ok := <-pending.respCh: - if !ok { - // Channel closed, session is dead - log.Error("response channel closed unexpectedly", nil) - return nil, fmt.Errorf("response channel closed") - } - - switch env.Type { - case protocol.MessageTypeStreamOpen: - so := env.StreamOpen - if so == nil { - return nil, fmt.Errorf("stream_open response payload is nil") - } - // 상태 코드 및 헤더 복원 (pseudo-header 제거). (ko) - // Restore status code and headers (strip pseudo-headers). (en) - statusStr := firstHeaderValue(so.Header, protocol.HeaderKeyStatus, strconv.Itoa(http.StatusOK)) - if sc, err := strconv.Atoi(statusStr); err == nil && sc > 0 { - statusCode = sc - } - for k, vs := range so.Header { - if k == protocol.HeaderKeyMethod || - k == protocol.HeaderKeyURL || - k == protocol.HeaderKeyHost || - k == protocol.HeaderKeyStatus { - continue - } - resp.Header[k] = append([]string(nil), vs...) - } - gotOpen = true - - case protocol.MessageTypeStreamData: - sd := env.StreamData - if sd == nil { - return nil, fmt.Errorf("stream_data response payload is nil") - } - - // 수신 측 ARQ: Seq 에 따라 분기하고, 연속 구간을 bodyBuf 에 순서대로 기록합니다. (ko) - // Receiver-side ARQ: handle Seq and append contiguous data to bodyBuf in order. (en) - switch { - case sd.Seq == expectedSeq: - if len(sd.Data) > 0 { - if _, err := bodyBuf.Write(sd.Data); err != nil { - return nil, fmt.Errorf("buffer stream_data response: %w", err) - } - } - expectedSeq++ - for { - data, ok := received[expectedSeq] - if !ok { - break - } - if len(data) > 0 { - if _, err := bodyBuf.Write(data); err != nil { - return nil, fmt.Errorf("buffer reordered stream_data response: %w", err) - } - } - delete(received, expectedSeq) - delete(lost, expectedSeq) - expectedSeq++ - } - - // AckSeq 이전 구간의 lost 항목 정리 - for seq := range lost { - if seq < expectedSeq { - delete(lost, seq) - } - } - - case sd.Seq > expectedSeq: - // 앞선 일부 Seq 들이 누락된 상태: 현재 프레임을 버퍼링하고 missing seq 들을 lost 에 추가. (ko) - // Missing earlier Seq: buffer this frame and mark missing seqs as lost. (en) - if len(sd.Data) > 0 { - bufCopy := make([]byte, len(sd.Data)) - copy(bufCopy, sd.Data) - received[sd.Seq] = bufCopy - } - for seq := expectedSeq; seq < sd.Seq && len(lost) < maxLostReport; seq++ { - if _, ok := lost[seq]; !ok { - lost[seq] = struct{}{} - } - } - - default: - // sd.Seq < expectedSeq 인 경우: 이미 처리했거나 Ack 로 커버된 프레임 → 무시. (ko) - // sd.Seq < expectedSeq: already processed/acked frame → ignore. (en) - } - - // 수신 측 StreamAck 전송: - // - AckSeq: 0부터 시작해 연속으로 수신 완료한 마지막 시퀀스 (expectedSeq-1) - // - LostSeqs: 현재 윈도우 내에서 누락된 시퀀스 중 상한 개수(maxLostReport)까지만 포함 (ko) - // Send receiver-side StreamAck: - // - AckSeq: last contiguously received sequence starting from 0 (expectedSeq-1) - // - LostSeqs: up to maxLostReport missing sequences in the current window. (en) - var ackSeq uint64 - if expectedSeq == 0 { - ackSeq = 0 - } else { - ackSeq = expectedSeq - 1 - } - - lostSeqs := make([]uint64, 0, len(lost)) - for seq := range lost { - if seq >= expectedSeq { - lostSeqs = append(lostSeqs, seq) - } - } - if len(lostSeqs) > 0 { - sort.Slice(lostSeqs, func(i, j int) bool { return lostSeqs[i] < lostSeqs[j] }) - if len(lostSeqs) > maxLostReport { - lostSeqs = lostSeqs[:maxLostReport] - } - } - - ackEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamAck, - StreamAck: &protocol.StreamAck{ - ID: streamID, - AckSeq: ackSeq, - LostSeqs: lostSeqs, - }, - } - if err := w.codec.Encode(w.sess, &ackEnv); err != nil { - return nil, fmt.Errorf("send stream ack: %w", err) - } - - case protocol.MessageTypeStreamClose: - sc := env.StreamClose - if sc == nil { - return nil, fmt.Errorf("stream_close response payload is nil") - } - // 스트림 종료: 지금까지 수신한 헤더/바디로 protocol.Response 를 완성합니다. (ko) - // Stream finished: complete 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 dtls", 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 type %q in stream response", env.Type) - } - } - } -} - -// nextHTTPStreamID 는 DTLS 세션 내 HTTP 요청에 사용할 고유 StreamID 를 생성합니다. (ko) -// nextHTTPStreamID generates a unique StreamID for HTTP requests on this DTLS session. (en) -func (w *dtlsSessionWrapper) nextHTTPStreamID() protocol.StreamID { - id := w.nextStreamID - w.nextStreamID++ - return protocol.StreamID(fmt.Sprintf("http-%d", id)) -} - -// firstHeaderValue 는 map[string][]string 형태의 헤더에서 첫 번째 값을 반환하고, -// 값이 없으면 기본값을 반환합니다. (ko) -// firstHeaderValue returns the first value for a header key in map[string][]string, -// or the provided default if the key is missing or empty. (en) -func firstHeaderValue(hdr map[string][]string, key, def string) string { - if hdr == nil { - return def - } - if vs, ok := hdr[key]; ok && len(vs) > 0 { - return vs[0] - } - 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 ( - sessionsMu sync.RWMutex - 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) + tunnelsByDomain = make(map[string]forwardTunnel) ) -func registerTunnelForDomain(domain string, sess *grpcTunnelSession, logger logging.Logger) string { +type forwardTunnel interface { + ForwardHTTP(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error +} + +type extendedConnectForwarder interface { + ForwardExtendedConnect(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error +} + +func registerTunnelForDomain(domain string, sess forwardTunnel, logger logging.Logger) string { d := strings.ToLower(strings.TrimSpace(domain)) if d == "" || sess == nil { return "" @@ -1190,172 +59,43 @@ func registerTunnelForDomain(domain string, sess *grpcTunnelSession, logger logg tunnelsMu.Lock() tunnelsByDomain[d] = sess tunnelsMu.Unlock() - - logger.Info("registered grpc tunnel for domain", logging.Fields{ - "domain": d, - }) + logger.Info("registered yamux tunnel for domain", logging.Fields{"domain": d}) return d } -func unregisterTunnelForDomain(domain string, sess *grpcTunnelSession, logger logging.Logger) { +func unregisterTunnelForDomain(domain string, sess forwardTunnel, logger logging.Logger) { d := strings.ToLower(strings.TrimSpace(domain)) if d == "" || sess == nil { return } tunnelsMu.Lock() - cur := tunnelsByDomain[d] - if cur == sess { + if current := tunnelsByDomain[d]; current == sess { delete(tunnelsByDomain, d) } tunnelsMu.Unlock() - - logger.Info("unregistered grpc tunnel for domain", logging.Fields{ - "domain": d, - }) + logger.Info("unregistered yamux 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)) +func getTunnelForHost(host string) forwardTunnel { + h := strings.ToLower(strings.TrimSpace(host)) if h == "" { return nil } + if name, _, err := net.SplitHostPort(h); err == nil { + h = name + } else if i := strings.LastIndex(h, ":"); i > -1 && !strings.Contains(h[i+1:], "]") { + h = h[:i] + } tunnelsMu.RLock() defer tunnelsMu.RUnlock() return tunnelsByDomain[h] } -// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다. -// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다. -// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다. -// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다. type statusRecorder struct { http.ResponseWriter status int } -func (w *statusRecorder) WriteHeader(code int) { - w.status = 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 lists response headers that are owned by the HopGate server. (en) var hopGateOwnedHeaders = map[string]struct{}{ "X-HopGate-Server": {}, "Strict-Transport-Security": {}, @@ -1363,69 +103,35 @@ var hopGateOwnedHeaders = map[string]struct{}{ "Referrer-Policy": {}, } -// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko) -// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/525). (en) -// -// 템플릿 로딩 우선순위: (ko) -// 1. HOP_ERROR_PAGES_DIR/.html (또는 ./errors/.html) (ko) -// 2. go:embed 로 내장된 templates/.html (ko) -// -// Template loading priority: (en) -// 1. HOP_ERROR_PAGES_DIR/.html (or ./errors/.html) (en) -// 2. go:embed'ed templates/.html (en) func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) { - // 공통 보안/식별 헤더를 best-effort 로 설정합니다. (ko) - // Configure common security and identity headers (best-effort). (en) if r != nil { setSecurityAndIdentityHeaders(w, r) } - - // Delegates actual HTML rendering to internal/errorpages. (en) - // 실제 HTML 렌더링은 internal/errorpages 패키지에 위임합니다. (ko) errorpages.Render(w, r, status) } -// setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko) -// setSecurityAndIdentityHeaders configures common security and identity headers for HopGate. (en) func setSecurityAndIdentityHeaders(w http.ResponseWriter, r *http.Request) { h := w.Header() - - // HopGate 로 구성된 서버임을 나타내는 식별 헤더 (ko) - // Header to indicate that this server is powered by HopGate. (en) h.Set("X-HopGate-Server", "hop-gate") - - // 기본 보안 헤더 설정 (ko) - // Basic security headers (best-effort). (en) h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") - - // HTTPS 요청에 대해서만 HSTS 헤더를 추가합니다. (ko) - // Only send HSTS for HTTPS requests. (en) if r != nil && r.TLS != nil { h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") } } -// hostDomainHandler 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 요청을 허용하는 래퍼입니다. -// Host 헤더에서 포트를 제거한 뒤 소문자 비교를 수행합니다. func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Handler) http.Handler { allowed := strings.ToLower(strings.TrimSpace(allowedDomain)) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if allowed != "" { host := r.Host - if i := strings.Index(host, ":"); i != -1 { - host = host[:i] + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } else { + host = strings.Trim(host, "[]") } - host = strings.ToLower(strings.TrimSpace(host)) - if host != allowed { - logger.Warn("rejecting request due to mismatched host", logging.Fields{ - "allowed_domain": allowed, - "request_host": host, - "path": r.URL.Path, - }) - // 메트릭/관리용 엔드포인트에 대해 호스트가 다르면 404 페이지로 응답하여 노출을 최소화합니다. (ko) - // For metrics/admin endpoints, respond with a 404 page when host mismatches to reduce exposure. (en) + if !strings.EqualFold(strings.TrimSpace(host), allowed) { + logger.Warn("rejecting request due to mismatched host", logging.Fields{"allowed_domain": allowed, "request_host": host, "path": r.URL.Path}) writeErrorPage(w, r, http.StatusNotFound) return } @@ -1434,66 +140,37 @@ 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) { - d := strings.ToLower(strings.TrimSpace(domain)) - if d == "" { +func (w *statusRecorder) WriteHeader(code int) { + if w.status != 0 { return } - w := &dtlsSessionWrapper{ - sess: sess, - bufferedReader: bufio.NewReaderSize(sess, protocol.GetDTLSReadBufferSize()), - codec: protocol.DefaultCodec, - logger: logger.With(logging.Fields{"component": "dtls_session_wrapper", "domain": d}), - pending: make(map[protocol.StreamID]*pendingRequest), - readerDone: make(chan struct{}), - streamSenders: make(map[protocol.StreamID]*streamSender), - } - - // Start background reader goroutine to demultiplex incoming responses - go w.readLoop() - - sessionsMu.Lock() - sessionsByDomain[d] = w - sessionsMu.Unlock() - - logger.Info("registered dtls session for domain", logging.Fields{ - "domain": d, - "sid": sess.ID(), - }) + w.status = code + w.ResponseWriter.WriteHeader(code) } -func getSessionForHost(host string) *dtlsSessionWrapper { - // host may contain port (e.g. "example.com:443"); strip port. - h := host - if i := strings.Index(h, ":"); i != -1 { - h = h[:i] +func (w *statusRecorder) Write(p []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) } - h = strings.ToLower(strings.TrimSpace(h)) - if h == "" { - return nil - } - sessionsMu.RLock() - defer sessionsMu.RUnlock() - return sessionsByDomain[h] + return w.ResponseWriter.Write(p) } +func (w *statusRecorder) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("underlying response writer does not support hijacking") + } + return hijacker.Hijack() +} + +func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter } + func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Handler { // ACME webroot (for HTTP-01) is read from env; must match HOP_ACME_WEBROOT used by lego. webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT")) @@ -1505,13 +182,13 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand allowedDomain := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_SERVER_DOMAIN"))) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // NOTE: /__hopgate_assets__/ 경로는 DTLS/백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko) + // NOTE: /__hopgate_assets__/ 경로는 백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko) // 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만, // 혹시라도 라우팅/구성이 꼬여서 이쪽으로 들어오는 경우를 방지하기 위해 // /__hopgate_assets__/ 요청은 여기서도 강제로 정적 핸들러로 처리합니다. (ko) // // The /__hopgate_assets__/ path must always serve static assets independently - // of DTLS/backend state. This handler is intended for the generic proxy path (/), + // of backend state. This handler is intended for the generic proxy path (/), // but as a safety net, we short-circuit asset requests here as well. (en) if strings.HasPrefix(r.URL.Path, "/__hopgate_assets__/") { if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil { @@ -1531,7 +208,7 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand // 상태 코드 캡처를 위한 래퍼 sr := &statusRecorder{ ResponseWriter: w, - status: http.StatusOK, + status: 0, } // 보안/식별 헤더를 공통으로 설정합니다. (ko) // Configure common security and identity headers. (en) @@ -1595,8 +272,8 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand return } - // 2. 일반 HTTP 요청은 활성 gRPC 터널을 통해 클라이언트로 포워딩합니다. (ko) - // 2. Regular HTTP requests are forwarded to clients over active gRPC tunnels. (en) + // 2. 일반 HTTP 요청은 활성 yamux 터널을 통해 클라이언트로 포워딩합니다. (ko) + // 2. Regular HTTP requests are forwarded to clients over an active yamux tunnel. (en) // 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. (ko) // For now, use a fixed logical service name "web"; this can be improved with a Router later. (en) serviceName := "web" @@ -1621,8 +298,8 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand return } - tunnel := getTunnelForHost(hostLower) - if tunnel == nil { + activeTunnel := getTunnelForHost(hostLower) + if activeTunnel == nil { log.Warn("no tunnel for host", logging.Fields{ "host": r.Host, }) @@ -1660,85 +337,59 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand // r.Body is consumed inside ForwardHTTP; ensure it is closed here. (en) defer r.Body.Close() - // 서버 측에서 gRPC 터널 → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해 + // 서버 측에서 yamux 터널 → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해 // 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며, // HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko) // Apply an overall timeout (default 15s, configurable via // HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the tunnel forward path so that // excessively slow backends surface as gateway timeouts. (en) ctx := r.Context() - if proxyTimeout > 0 { + if proxyTimeout > 0 && !isSSERequest(r) { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, proxyTimeout) defer cancel() } - type forwardResult struct { - resp *protocol.Response - err error - } - resultCh := make(chan forwardResult, 1) - - go func() { - select { - case <-ctx.Done(): - // Context cancelled, do not proceed. + if isExtendedConnectWebSocketRequest(r) { + extendedTunnel, ok := activeTunnel.(extendedConnectForwarder) + if !ok { + writeErrorPage(sr, r, http.StatusNotImplemented) return - default: - resp, err := tunnel.ForwardHTTP(ctx, logger, r, serviceName) - resultCh <- forwardResult{resp: resp, err: err} } - }() - - var protoResp *protocol.Response - - select { - case <-ctx.Done(): - log.Error("forward over tunnel timed out", logging.Fields{ - "timeout_seconds": int64(proxyTimeout.Seconds()), - "error": ctx.Err().Error(), - }) - observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc() - writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) + if err := extendedTunnel.ForwardExtendedConnect(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("HTTP/2 Extended CONNECT forwarding failed", logging.Fields{"error": err.Error()}) + writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) + } return + } - case res := <-resultCh: - if res.err != nil { - log.Error("forward over tunnel failed", logging.Fields{ - "error": res.err.Error(), - }) + if isWebSocketRequest(r) { + wsTunnel, ok := activeTunnel.(websocketForwarder) + if !ok { + writeErrorPage(sr, r, http.StatusNotImplemented) + return + } + if err := wsTunnel.ForwardWebSocket(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("WebSocket forwarding failed", logging.Fields{"error": err.Error()}) + writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) + } + return + } + + if err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 { + log.Error("forward over tunnel failed", logging.Fields{"error": err.Error()}) + if ctx.Err() != nil { + observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc() + writeErrorPage(sr, r, errorpages.StatusGatewayTimeout) + } else { observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc() writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) - return - } - protoResp = res.resp - } - - // 응답 헤더/바디 복원 - for k, vs := range protoResp.Header { - // HopGate 가 소유한 보안/식별 헤더는 백엔드 값 대신 서버 값만 사용합니다. (ko) - // For security/identity headers owned by HopGate, ignore backend values. (en) - if _, ok := hopGateOwnedHeaders[http.CanonicalHeaderKey(k)]; ok { - continue - } - for _, v := range vs { - sr.Header().Add(k, v) - } - } - if protoResp.Status == 0 { - protoResp.Status = http.StatusOK - } - sr.WriteHeader(protoResp.Status) - if len(protoResp.Body) > 0 { - if _, err := sr.Write(protoResp.Body); err != nil { - log.Warn("failed to write http response body", logging.Fields{ - "error": err.Error(), - }) } + return } log.Info("http request completed", logging.Fields{ - "status": protoResp.Status, + "status": sr.status, "elapsed_ms": time.Since(start).Milliseconds(), "service_name": serviceName, }) @@ -1761,7 +412,6 @@ func main() { // 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선) httpListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTP_LISTEN") httpsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTPS_LISTEN") - dtlsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_DTLS_LISTEN") domainEnv := getEnvOrPanic(logger, "HOP_SERVER_DOMAIN") debugEnv := getEnvOrPanic(logger, "HOP_SERVER_DEBUG") @@ -1778,7 +428,6 @@ func main() { logger.Info("validated server env vars", logging.Fields{ "HOP_SERVER_HTTP_LISTEN": httpListenEnv, "HOP_SERVER_HTTPS_LISTEN": httpsListenEnv, - "HOP_SERVER_DTLS_LISTEN": dtlsListenEnv, "HOP_SERVER_DOMAIN": domainEnv, "HOP_SERVER_DEBUG": debugEnv, }) @@ -1787,13 +436,13 @@ func main() { observability.MustRegister() logger.Info("hop-gate server starting", logging.Fields{ - "stack": "prometheus-loki-grafana", - "version": version, - "http_listen": cfg.HTTPListen, - "https_listen": cfg.HTTPSListen, - "dtls_listen": cfg.DTLSListen, - "domain": cfg.Domain, - "debug": cfg.Debug, + "stack": "prometheus-loki-grafana", + "version": version, + "http_listen": cfg.HTTPListen, + "https_listen": cfg.HTTPSListen, + "tunnel_listen": cfg.TunnelListen, + "domain": cfg.Domain, + "debug": cfg.Debug, }) ctx := context.Background() @@ -1824,123 +473,35 @@ func main() { }) } - // gRPC 터널 핸드셰이크에서 사용할 도메인 검증기 구성. (ko) - // Construct domain validator to be used by the gRPC tunnel handshake. (en) + // yamux control stream에서 사용할 도메인 검증기 구성. (ko) + // Construct domain validator for the yamux control stream. (en) domainValidator := admin.NewEntDomainValidator(logger, dbClient) - // 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되 - // ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다. - // 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed - // but ACME is still attempted in staging mode. - var tlsCfg *tls.Config - - // ACME 를 위해 사용할 도메인 목록 구성 var domains []string if cfg.Domain != "" { domains = append(domains, cfg.Domain) } domains = append(domains, cfg.ProxyDomains...) - - // Debug 모드에서는 반드시 Staging CA 를 사용하도록 강제 if cfg.Debug { _ = os.Setenv("HOP_ACME_USE_STAGING", "true") } - - // HOP_ACME_STANDALONE_ONLY=true 인 경우, ACME 인증서만 발급/갱신하고 프로세스를 종료합니다. - // 이 모드는 HTTP/DTLS 서버를 띄우지 않고 lego(ACME client)만 단독으로 실행할 때 사용합니다. - standaloneOnly := func() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_ACME_STANDALONE_ONLY"))) - switch v { - case "1", "true", "yes", "y", "on": - return true - default: - return false - } - }() + standaloneOnly := strings.EqualFold(strings.TrimSpace(os.Getenv("HOP_ACME_STANDALONE_ONLY")), "true") if standaloneOnly { - logger.Info("running ACME standalone-only mode", logging.Fields{ - "domains": domains, - "use_staging": cfg.Debug, - }) - - // ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장 - // 이 호출이 끝나면 해당 도메인에 대한 인증서가 HOP_ACME_CACHE_DIR 에 준비되어 있어야 합니다. acmeCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() - if _, err := acme.NewLegoManagerFromEnv(acmeCtx, logger, domains); err != nil { - logger.Error("acme standalone mode failed", logging.Fields{ - "error": err.Error(), - "domains": domains, - }) + logger.Error("acme standalone mode failed", logging.Fields{"error": err.Error()}) os.Exit(1) } - - logger.Info("acme standalone mode completed successfully, exiting process", logging.Fields{ - "domains": domains, - }) return } - - // ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장 acmeMgr, err := acme.NewLegoManagerFromEnv(ctx, logger, domains) if err != nil { - logger.Error("failed to initialize ACME lego manager", logging.Fields{ - "error": err.Error(), - "domains": domains, - }) + logger.Error("failed to initialize ACME lego manager", logging.Fields{"error": err.Error(), "domains": domains}) os.Exit(1) } acmeTLSCfg := acmeMgr.TLSConfig() - logger.Info("acme tls config initialized", logging.Fields{ - "domains": domains, - "use_staging": cfg.Debug, - }) - - if cfg.Debug { - // Debug 모드: DTLS 자체는 self-signed localhost 인증서를 사용하지만, - // ACME Staging 을 통해 실제 도메인 인증서도 동시에 관리합니다. - tlsCfg, err = dtls.NewSelfSignedLocalhostConfig() - if err != nil { - logger.Error("failed to create self-signed localhost cert", logging.Fields{ - "error": err.Error(), - }) - os.Exit(1) - } - logger.Warn("using self-signed localhost certificate for DTLS (debug mode)", logging.Fields{ - "note": "acme is running in staging mode; do not use this configuration in production", - }) - } else { - // Production 모드: DTLS/HTTPS 모두 ACME 인증서를 직접 사용 - tlsCfg = acmeTLSCfg - } - - // DTLS 서버는 HOP_SERVER_DOMAIN 으로 지정된 도메인에 대한 연결만 수락해야 합니다. - // 이를 위해 GetCertificate 를 래핑하여 SNI 검증 로직을 추가합니다. - // 주의: HTTPS 서버용 tlsCfg 에 영향을 주지 않도록 Clone()을 사용합니다. - dtlsTLSConfig := tlsCfg.Clone() - if cfg.Domain != "" { - nextGetCert := dtlsTLSConfig.GetCertificate - dtlsTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - // SNI 검증: 설정된 도메인과 일치하지 않으면 핸드셰이크 거부 - // ServerName이 비어있는 경우(클라이언트가 SNI 미전송 시)는 검증을 건너뜁니다. - if hello.ServerName != "" && !strings.EqualFold(hello.ServerName, cfg.Domain) { - return nil, fmt.Errorf("dtls: invalid SNI %q, expected %q", hello.ServerName, cfg.Domain) - } - - // 기존 로직 수행 - if nextGetCert != nil { - return nextGetCert(hello) - } - // Debug 모드 등에서 GetCertificate 가 없는 경우 Certificates 필드 사용 - if len(dtlsTLSConfig.Certificates) > 0 { - return &dtlsTLSConfig.Certificates[0], nil - } - return nil, fmt.Errorf("dtls: no certificate found for %q", hello.ServerName) - } - } - // 5. HTTP / HTTPS 서버 시작 // 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며, // 기본값은 15초입니다. (ko) @@ -2015,13 +576,33 @@ func main() { adminHandler.RegisterRoutes(adminMux) httpMux.Handle("/api/v1/admin/", hostDomainHandler(allowedDomain, logger, adminMux)) - // 기본 HTTP → DTLS Proxy 엔트리 포인트 + // 기본 HTTP → yamux Proxy 엔트리 포인트 httpMux.Handle("/", httpHandler) - // gRPC server for client tunnels (OpenTunnel). (en) - // 클라이언트 터널(OpenTunnel)을 처리하는 gRPC 서버 인스턴스를 생성합니다. (ko) - grpcSrv := grpc.NewServer() - protocolpb.RegisterHopGateTunnelServer(grpcSrv, newGRPCTunnelServer(logger, domainValidator)) + // HTTP/3 uses the same ingress handler and certificates as HTTPS, but listens + // on UDP separately from the TCP listener. + if len(acmeTLSCfg.NextProtos) == 0 { + acmeTLSCfg.NextProtos = []string{"h2", "http/1.1"} + } + http3Server := &http3.Server{ + Addr: cfg.HTTPSListen, + Handler: nil, + TLSConfig: http3.ConfigureTLSConfig(acmeTLSCfg.Clone()), + } + publicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor < 3 { + _ = http3Server.SetQUICHeaders(w.Header()) + } + httpMux.ServeHTTP(w, r) + }) + http3Server.Handler = publicHandler + + go func() { + if err := serveYamuxTunnel(context.Background(), cfg.TunnelListen, acmeTLSCfg, logger, domainValidator); err != nil { + logger.Error("yamux tunnel server stopped", logging.Fields{"error": err.Error()}) + } + }() + logger.Info("yamux transport enabled", logging.Fields{"listen": cfg.TunnelListen}) // HTTP: 평문 포트 httpSrv := &http.Server{ @@ -2039,16 +620,9 @@ func main() { } }() - // 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{ Addr: cfg.HTTPSListen, - Handler: grpcOrHTTPHandler(grpcSrv, httpMux), + Handler: publicHandler, TLSConfig: acmeTLSCfg, } go func() { @@ -2062,7 +636,14 @@ func main() { } }() - // DTLS 레이어 제거 이후에는 gRPC 및 HTTP/HTTPS 서버 goroutine 만 유지합니다. (ko) - // After removing the DTLS layer, only the gRPC and HTTP/HTTPS servers are kept running. (en) + go func() { + logger.Info("http/3 server listening", logging.Fields{"addr": cfg.HTTPSListen}) + if err := http3Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http/3 server error", logging.Fields{"error": err.Error()}) + } + }() + + // yamux 및 HTTP/HTTPS 서버 goroutine을 유지합니다. (ko) + // Keep the yamux and HTTP/HTTPS server goroutines running. (en) select {} } diff --git a/cmd/server/streaming_test.go b/cmd/server/streaming_test.go new file mode 100644 index 0000000..fd92379 --- /dev/null +++ b/cmd/server/streaming_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dalbodeule/hop-gate/internal/logging" +) + +type noopLogger struct{} + +func (noopLogger) Debug(string, logging.Fields) {} +func (noopLogger) Info(string, logging.Fields) {} +func (noopLogger) Warn(string, logging.Fields) {} +func (noopLogger) Error(string, logging.Fields) {} +func (l noopLogger) With(logging.Fields) logging.Logger { return l } + +type streamingTestTunnel struct { + forwardHTTPCalled bool + extendedConnectCalled bool + deadlineSeen bool +} + +func (t *streamingTestTunnel) ForwardHTTP(ctx context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error { + t.forwardHTTPCalled = true + _, t.deadlineSeen = ctx.Deadline() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("data: ready\n\n")) + return err +} + +func (t *streamingTestTunnel) ForwardExtendedConnect(_ context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error { + t.extendedConnectCalled = true + w.WriteHeader(http.StatusOK) + return nil +} + +func TestSSERequestStreamsWithoutProxyTimeout(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "sse-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodGet, "http://"+domain+"/events", nil) + req.Host = domain + req.Header.Set("Accept", "text/event-stream") + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req) + + if !tunnel.forwardHTTPCalled { + t.Fatal("expected SSE request to use HTTP forwarder") + } + if tunnel.deadlineSeen { + t.Fatal("expected SSE request to avoid the normal proxy timeout") + } + if got := recorder.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("Content-Type = %q, want text/event-stream", got) + } + if got := recorder.Body.String(); got != "data: ready\n\n" { + t.Fatalf("body = %q, want SSE event", got) + } +} + +func TestHTTP2ExtendedConnectUsesDedicatedForwarder(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "h2-connect-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodConnect, "https://"+domain+"/socket", nil) + req.Host = domain + req.ProtoMajor = 2 + req.ProtoMinor = 0 + req.Proto = "websocket" + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Second).ServeHTTP(recorder, req) + + if !tunnel.extendedConnectCalled { + t.Fatal("expected HTTP/2 Extended CONNECT forwarder to be called") + } + if tunnel.forwardHTTPCalled { + t.Fatal("did not expect regular HTTP forwarder for Extended CONNECT") + } + if got := recorder.Code; got != http.StatusOK { + t.Fatalf("status = %d, want %d", got, http.StatusOK) + } +} + +func TestHTTP3ExtendedConnectDetection(t *testing.T) { + req := httptest.NewRequest(http.MethodConnect, "https://h3.example/socket", nil) + req.ProtoMajor = 3 + req.ProtoMinor = 0 + req.Proto = "websocket" + + if !isExtendedConnectWebSocketRequest(req) { + t.Fatal("expected HTTP/3 Extended CONNECT WebSocket request to be detected") + } +} + +func TestHTTP3SSERequestUsesStreamingPolicy(t *testing.T) { + tunnel := &streamingTestTunnel{} + logger := noopLogger{} + domain := "h3-sse-test.example" + registerTunnelForDomain(domain, tunnel, logger) + defer unregisterTunnelForDomain(domain, tunnel, logger) + + req := httptest.NewRequest(http.MethodGet, "https://"+domain+"/events", nil) + req.Host = domain + req.ProtoMajor = 3 + req.ProtoMinor = 0 + req.Header.Set("Accept", "text/event-stream") + recorder := httptest.NewRecorder() + + newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req) + + if tunnel.deadlineSeen { + t.Fatal("expected HTTP/3 SSE request to avoid the normal proxy timeout") + } + if got := recorder.Body.String(); got != "data: ready\n\n" { + t.Fatalf("body = %q, want SSE event", got) + } +} diff --git a/cmd/server/yamux_tunnel.go b/cmd/server/yamux_tunnel.go new file mode 100644 index 0000000..91c7759 --- /dev/null +++ b/cmd/server/yamux_tunnel.go @@ -0,0 +1,300 @@ +package main + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/tunnel" +) + +type yamuxTunnelSession struct { + session *tunnel.Session + logger logging.Logger +} + +func (t *yamuxTunnelSession) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { + if ctx == nil { + ctx = context.Background() + } + meta := tunnel.StreamMeta{ + Kind: "http", + Service: serviceName, + Method: req.Method, + Path: req.URL.RequestURI(), + Host: req.Host, + Headers: req.Header, + } + stream, err := t.session.Open(ctx, meta) + if err != nil { + return err + } + defer stream.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = stream.SetDeadline(deadline) + } + + request := req.Clone(ctx) + request.RequestURI = "" + if err := request.Write(stream); err != nil { + return fmt.Errorf("write HTTP request to yamux stream: %w", err) + } + resp, err := http.ReadResponse(bufio.NewReader(stream), req) + if err != nil { + return fmt.Errorf("read HTTP response from yamux stream: %w", err) + } + defer resp.Body.Close() + for key, values := range resp.Header { + if _, owned := hopGateOwnedHeaders[http.CanonicalHeaderKey(key)]; owned { + continue + } + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + if _, err := io.Copy(flushingResponseWriter{ResponseWriter: w}, resp.Body); err != nil { + return fmt.Errorf("stream HTTP response body from yamux: %w", err) + } + return nil +} + +type websocketForwarder interface { + ForwardWebSocket(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error +} + +func isWebSocketRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func isExtendedConnectWebSocketRequest(r *http.Request) bool { + return r.ProtoMajor >= 2 && r.Method == http.MethodConnect && + strings.EqualFold(r.Proto, "websocket") +} + +func isSSERequest(r *http.Request) bool { + for _, value := range r.Header.Values("Accept") { + for _, mediaType := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(strings.SplitN(mediaType, ";", 2)[0]), "text/event-stream") { + return true + } + } + } + return false +} + +func (t *yamuxTunnelSession) ForwardWebSocket(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { + if ctx == nil { + ctx = context.Background() + } + if _, ok := w.(http.Hijacker); !ok { + return fmt.Errorf("websocket upgrade requires HTTP/1.1 hijacking") + } + stream, err := t.session.Open(ctx, tunnel.StreamMeta{ + Kind: "websocket", + Service: serviceName, + Method: req.Method, + Path: req.URL.RequestURI(), + Host: req.Host, + Headers: req.Header, + }) + if err != nil { + return err + } + defer stream.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = stream.SetDeadline(deadline) + } + request := req.Clone(ctx) + request.RequestURI = "" + if err := request.Write(stream); err != nil { + return fmt.Errorf("write WebSocket request to yamux stream: %w", err) + } + backendReader := bufio.NewReader(stream) + backendResponse, err := http.ReadResponse(backendReader, req) + if err != nil { + return fmt.Errorf("read WebSocket handshake from yamux stream: %w", err) + } + if backendResponse.StatusCode != http.StatusSwitchingProtocols { + defer backendResponse.Body.Close() + w.WriteHeader(backendResponse.StatusCode) + _, _ = io.Copy(w, backendResponse.Body) + return fmt.Errorf("backend rejected WebSocket upgrade with status %d", backendResponse.StatusCode) + } + + hijacker := w.(http.Hijacker) + clientConn, clientRW, err := hijacker.Hijack() + if err != nil { + return fmt.Errorf("hijack public WebSocket connection: %w", err) + } + defer clientConn.Close() + if err := backendResponse.Write(clientRW); err != nil { + return fmt.Errorf("write WebSocket handshake to public client: %w", err) + } + if err := clientRW.Flush(); err != nil { + return fmt.Errorf("flush WebSocket handshake: %w", err) + } + + return relayConnections(clientRW.Reader, stream, clientConn, backendReader) +} + +func (t *yamuxTunnelSession) ForwardExtendedConnect(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string, w http.ResponseWriter) error { + if ctx == nil { + ctx = context.Background() + } + stream, err := t.session.Open(ctx, tunnel.StreamMeta{ + Kind: "websocket", + Service: serviceName, + Method: req.Method, + Path: req.URL.RequestURI(), + Host: req.Host, + Headers: req.Header, + }) + if err != nil { + return err + } + defer stream.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = stream.SetDeadline(deadline) + } + + request := req.Clone(ctx) + request.Method = http.MethodGet + request.RequestURI = "" + request.Body = http.NoBody + request.ContentLength = 0 + request.Header = request.Header.Clone() + request.Header.Del(":protocol") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + if err := request.Write(stream); err != nil { + return fmt.Errorf("write translated WebSocket request to yamux stream: %w", err) + } + + backendReader := bufio.NewReader(stream) + backendResponse, err := http.ReadResponse(backendReader, request) + if err != nil { + return fmt.Errorf("read translated WebSocket handshake from yamux stream: %w", err) + } + defer backendResponse.Body.Close() + if backendResponse.StatusCode != http.StatusSwitchingProtocols { + w.WriteHeader(http.StatusBadGateway) + return fmt.Errorf("local WebSocket rejected Extended CONNECT with status %d", backendResponse.StatusCode) + } + + for key, values := range backendResponse.Header { + if _, owned := hopGateOwnedHeaders[http.CanonicalHeaderKey(key)]; owned { + continue + } + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + return relayConnections(req.Body, stream, flushingResponseWriter{ResponseWriter: w}, backendReader) +} + +type flushingResponseWriter struct{ http.ResponseWriter } + +func (w flushingResponseWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } + return n, err +} + +func relayConnections(clientReader io.Reader, stream io.Writer, clientWriter io.Writer, backend io.Reader) error { + result := make(chan error, 2) + go func() { + _, err := io.Copy(stream, clientReader) + result <- err + }() + go func() { + _, err := io.Copy(clientWriter, backend) + result <- err + }() + return <-result +} + +func serveYamuxTunnel(ctx context.Context, address string, tlsConfig *tls.Config, logger logging.Logger, validator tunnel.DomainValidator) error { + listener, err := tls.Listen("tcp", address, tlsConfig) + if err != nil { + return fmt.Errorf("listen for yamux tunnel: %w", err) + } + defer listener.Close() + logger.Info("yamux tunnel listener started", logging.Fields{"addr": address}) + + for { + conn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + logger.Error("yamux tunnel accept failed", logging.Fields{"error": err.Error()}) + continue + } + go handleYamuxTunnel(ctx, conn, logger, validator) + } +} + +func handleYamuxTunnel(ctx context.Context, conn net.Conn, logger logging.Logger, validator tunnel.DomainValidator) { + session, err := tunnel.NewServer(conn) + if err != nil { + logger.Error("create yamux server session failed", logging.Fields{"error": err.Error()}) + return + } + defer session.Close() + + control, err := session.Accept(ctx) + if err != nil { + logger.Error("accept yamux control stream failed", logging.Fields{"error": err.Error()}) + return + } + defer control.Close() + if control.Meta.Kind != "control" || strings.TrimSpace(control.Meta.Domain) == "" || strings.TrimSpace(control.Meta.Target) == "" { + logger.Warn("invalid yamux control metadata", logging.Fields{"kind": control.Meta.Kind}) + return + } + apiKeys := control.Meta.Headers["X-HopGate-API-Key"] + if len(apiKeys) == 0 || strings.TrimSpace(apiKeys[0]) == "" { + logger.Warn("yamux control stream missing API key", logging.Fields{"domain": control.Meta.Domain}) + return + } + if validator != nil { + if err := validator.ValidateDomainAPIKey(ctx, control.Meta.Domain, apiKeys[0]); err != nil { + logger.Warn("yamux tunnel authentication failed", logging.Fields{"domain": control.Meta.Domain, "error": err.Error()}) + return + } + } + + tunnelSession := &yamuxTunnelSession{session: session, logger: logger.With(logging.Fields{"domain": control.Meta.Domain})} + domain := registerTunnelForDomain(control.Meta.Domain, tunnelSession, logger) + defer unregisterTunnelForDomain(domain, tunnelSession, logger) + logger.Info("yamux tunnel authenticated", logging.Fields{"domain": domain, "local_target": control.Meta.Target}) + + // The server opens HTTP streams. The client opens only the control stream, + // so poll the session state rather than consuming the server's own streams. + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for !session.IsClosed() { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/go.mod b/go.mod index 3e160e1..23e4529 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/dalbodeule/hop-gate -go 1.25.4 +go 1.27.0 require ( entgo.io/ent v0.14.5 github.com/go-acme/lego/v4 v4.28.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/yamux v0.1.2 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.19.0 - golang.org/x/net v0.47.0 - google.golang.org/grpc v1.76.0 - google.golang.org/protobuf v1.36.10 + github.com/quic-go/quic-go v0.62.0 ) require ( @@ -30,14 +30,16 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index e1ca9b7..666a70a 100644 --- a/go.sum +++ b/go.sum @@ -22,22 +22,20 @@ github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0 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/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/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/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -52,8 +50,6 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -62,49 +58,39 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= -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/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -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= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/acme/acme.go b/internal/acme/acme.go index c547088..453ff5e 100644 --- a/internal/acme/acme.go +++ b/internal/acme/acme.go @@ -29,8 +29,8 @@ import ( // Manager 는 ACME 기반 인증서 관리를 추상화합니다. (ko) // Manager abstracts ACME-based certificate management. (en) type Manager interface { - // TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다. (ko) - // TLSConfig returns a tls.Config to be used by HTTPS and DTLS servers. (en) + // TLSConfig 는 HTTPS 및 TLS 터널 listener에 주입할 tls.Config 를 반환합니다. (ko) + // TLSConfig returns a tls.Config for the HTTPS and TLS tunnel listeners. (en) TLSConfig() *tls.Config } diff --git a/internal/admin/domain_validator.go b/internal/admin/domain_validator.go index 5e045e2..e74c544 100644 --- a/internal/admin/domain_validator.go +++ b/internal/admin/domain_validator.go @@ -8,8 +8,8 @@ import ( "github.com/dalbodeule/hop-gate/ent" entdomain "github.com/dalbodeule/hop-gate/ent/domain" - "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/tunnel" ) // entDomainValidator 는 ent.Client 를 사용해 Domain 테이블에서 @@ -22,7 +22,7 @@ type entDomainValidator struct { // NewEntDomainValidator 는 ent 기반 DomainValidator 를 생성합니다. // - domain 파라미터는 "host" 또는 "host:port" 형태 모두 허용하며, // DB 조회 시에는 host 부분만 사용합니다. -func NewEntDomainValidator(logger logging.Logger, client *ent.Client) dtls.DomainValidator { +func NewEntDomainValidator(logger logging.Logger, client *ent.Client) tunnel.DomainValidator { return &entDomainValidator{ logger: logger.With(logging.Fields{"component": "domain_validator"}), client: client, diff --git a/internal/config/config.go b/internal/config/config.go index 7cc776e..e12a0ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,7 @@ type LokiConfig struct { type ServerConfig struct { HTTPListen string // 예: ":80" HTTPSListen string // 예: ":443" - DTLSListen string // 예: ":443" + TunnelListen string // TLS + yamux tunnel listener, 예: ":7443" Domain string // 메인 도메인 ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인 Debug bool // true 이면 디버그 모드 (예: self-signed 인증서 신뢰, 검증 스킵 등) @@ -40,7 +40,7 @@ type ServerConfig struct { // ClientConfig 는 클라이언트 프로세스 설정을 담습니다. // 현재 클라이언트는 다음 4가지 설정만 사용합니다. -// - ServerAddr : DTLS 서버 주소 (host:port) +// - ServerAddr : 터널 서버 주소 (host:port) // - Domain : 서버에서 등록된 도메인 (예: api.example.com) // - ClientAPIKey : 도메인에 매핑된 64자 클라이언트 API Key // - LocalTarget : 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080) @@ -48,7 +48,7 @@ type ServerConfig struct { // 값은 .env/환경변수와 CLI 인자를 조합해 구성하며, // CLI 인자가 우선, env 가 후순위로 적용됩니다. type ClientConfig struct { - ServerAddr string // DTLS 서버 주소 (host:port) + ServerAddr string // 터널 서버 주소 (host:port) Domain string // 서버에서 등록된 도메인 (예: api.example.com) ClientAPIKey string // 도메인에 매핑된 64자 클라이언트 API Key LocalTarget string // 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080) @@ -224,7 +224,7 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) { cfg := &ServerConfig{ HTTPListen: getEnvOrDefault("HOP_SERVER_HTTP_LISTEN", ":80"), HTTPSListen: getEnvOrDefault("HOP_SERVER_HTTPS_LISTEN", ":443"), - DTLSListen: getEnvOrDefault("HOP_SERVER_DTLS_LISTEN", ":443"), + TunnelListen: getEnvOrDefault("HOP_SERVER_TUNNEL_LISTEN", ":7443"), Domain: os.Getenv("HOP_SERVER_DOMAIN"), ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"), Debug: getEnvBool("HOP_SERVER_DEBUG", false), diff --git a/internal/dtls/dtls.go b/internal/dtls/dtls.go deleted file mode 100644 index e11169a..0000000 --- a/internal/dtls/dtls.go +++ /dev/null @@ -1,23 +0,0 @@ -package dtls - -import "io" - -// Session 은 DTLS 위의 양방향 스트림을 추상화합니다. -type Session interface { - io.ReadWriteCloser - ID() string -} - -// Server 는 다중 클라이언트 DTLS 세션을 관리하는 추상 인터페이스입니다. -type Server interface { - Accept() (Session, error) - Close() error -} - -// Client 는 단일 서버와의 DTLS 세션을 관리하는 추상 인터페이스입니다. -type Client interface { - Connect() (Session, error) - Close() error -} - -// 실제 구현은 향후 pion/dtls 등을 사용해 추가합니다. diff --git a/internal/dtls/handshake.go b/internal/dtls/handshake.go deleted file mode 100644 index 009ca1d..0000000 --- a/internal/dtls/handshake.go +++ /dev/null @@ -1,210 +0,0 @@ -package dtls - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/dalbodeule/hop-gate/internal/logging" -) - -// DomainValidator 는 (domain, clientAPIKey) 조합이 유효한지 검증하는 인터페이스입니다. -// 실제 구현에서는 ent + PostgreSQL 을 사용해 Domain 테이블을 조회하면 됩니다. -type DomainValidator interface { - ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error -} - -// ServerHandshakeResult 는 서버 측에서 핸드셰이크가 완료된 후의 정보를 담습니다. -type ServerHandshakeResult struct { - Domain string -} - -// ClientHandshakeResult 는 클라이언트 측에서 핸드셰이크가 완료된 후의 정보를 담습니다. -type ClientHandshakeResult struct { - Domain string - Message string -} - -// handshakeRequest 는 클라이언트가 최초 DTLS 연결 후 서버로 보내는 메시지입니다. -// - Domain: 사용할 도메인 (예: api.example.com) -// - ClientAPIKey: 관리 plane 을 통해 발급받은 64자 API Key -type handshakeRequest struct { - Domain string `json:"domain"` - ClientAPIKey string `json:"client_api_key"` -} - -// handshakeResponse 는 서버가 핸드셰이크 결과를 클라이언트로 돌려줄 때 사용하는 메시지입니다. -type handshakeResponse struct { - OK bool `json:"ok"` - Message string `json:"message"` - Domain string `json:"domain"` -} - -// PerformServerHandshake 는 서버 측에서 DTLS 세션이 생성된 직후 호출되어 -// 클라이언트가 보낸 (domain, client_api_key)를 검증합니다. -// -// 성공 시: -// - 서버 로그에 "어떤 도메인이 연결되었는지" 기록 -// - 클라이언트로 OK 응답을 전송 -// - ServerHandshakeResult 에 도메인 정보를 담아 반환 -func PerformServerHandshake( - ctx context.Context, - sess Session, - validator DomainValidator, - logger logging.Logger, -) (*ServerHandshakeResult, error) { - log := logger.With(logging.Fields{"phase": "dtls_handshake", "side": "server"}) - - if err := ctx.Err(); err != nil { - return nil, err - } - - var req handshakeRequest - // NOTE: pion/dtls 는 application plaintext 를 Caller's buffer 에 복호화하므로, - // JSON 디코더가 사용하는 버퍼 크기가 너무 작으면 "dtls: buffer too small" 이 발생할 수 있습니다. - // 이를 피하기 위해 충분히 큰 bufio.Reader(예: 64KiB)를 사용합니다. (ko) - // pion/dtls decrypts application data into the buffer provided by the caller. - // To avoid "dtls: buffer too small" errors when JSON payloads are larger than - // the default decoder buffer, we wrap the session in a bufio.Reader with a - // sufficiently large size (e.g. 64KiB). (en) - dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024)) - - if err := dec.Decode(&req); err != nil { - log.Error("failed to read handshake request", logging.Fields{ - "error": err.Error(), - }) - return nil, fmt.Errorf("read handshake request: %w", err) - } - - req.Domain = stringTrimSpace(req.Domain) - req.ClientAPIKey = stringTrimSpace(req.ClientAPIKey) - - if req.Domain == "" || req.ClientAPIKey == "" { - _ = writeHandshakeResponse(sess, handshakeResponse{ - OK: false, - Message: "domain and client_api_key are required", - Domain: req.Domain, - }) - return nil, fmt.Errorf("invalid handshake parameters") - } - - if err := validator.ValidateDomainAPIKey(ctx, req.Domain, req.ClientAPIKey); err != nil { - log.Warn("domain/api_key validation failed", logging.Fields{ - "domain": req.Domain, - "error": err.Error(), - }) - _ = writeHandshakeResponse(sess, handshakeResponse{ - OK: false, - Message: "invalid domain or api key", - Domain: req.Domain, - }) - return nil, fmt.Errorf("handshake validation failed: %w", err) - } - - // 검증 성공 - log.Info("dtls handshake success", logging.Fields{ - "domain": req.Domain, - }) - - if err := writeHandshakeResponse(sess, handshakeResponse{ - OK: true, - Message: "handshake ok", - Domain: req.Domain, - }); err != nil { - log.Error("failed to write handshake response", logging.Fields{ - "domain": req.Domain, - "error": err.Error(), - }) - return nil, fmt.Errorf("write handshake response: %w", err) - } - - return &ServerHandshakeResult{ - Domain: req.Domain, - }, nil -} - -// PerformClientHandshake 는 클라이언트 측에서 DTLS 세션이 생성된 직후 호출되어 -// 서버로 (domain, client_api_key)를 전송하고 결과를 검증합니다. -// -// localTarget 은 "로컬에서 요청할 서버 주소" (예: 127.0.0.1:8080) 로, -// 핸드셰이크 성공 시 로그에 함께 출력됩니다. -func PerformClientHandshake( - ctx context.Context, - sess Session, - logger logging.Logger, - domain string, - clientAPIKey string, - localTarget string, -) (*ClientHandshakeResult, error) { - log := logger.With(logging.Fields{"phase": "dtls_handshake", "side": "client"}) - - if err := ctx.Err(); err != nil { - return nil, err - } - - req := handshakeRequest{ - Domain: stringTrimSpace(domain), - ClientAPIKey: stringTrimSpace(clientAPIKey), - } - - if req.Domain == "" || req.ClientAPIKey == "" { - return nil, fmt.Errorf("domain and client_api_key are required") - } - - if err := writeHandshakeRequest(sess, req); err != nil { - log.Error("failed to write handshake request", logging.Fields{ - "error": err.Error(), - }) - return nil, fmt.Errorf("write handshake request: %w", err) - } - - var resp handshakeResponse - // 클라이언트 측에서도 동일하게 큰 버퍼를 사용해 "buffer too small" 오류를 방지합니다. (ko) - // Use the same larger buffer on the client side as well. (en) - dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024)) - - if err := dec.Decode(&resp); err != nil { - log.Error("failed to read handshake response", logging.Fields{ - "error": err.Error(), - }) - return nil, fmt.Errorf("read handshake response: %w", err) - } - - if !resp.OK { - log.Error("dtls handshake failed", logging.Fields{ - "domain": req.Domain, - "message": resp.Message, - }) - return nil, fmt.Errorf("handshake failed: %s", resp.Message) - } - - // 성공 로그: 연결 성공 메시지 + 도메인 + 로컬에서 요청할 서버 주소 - log.Info("dtls handshake success", logging.Fields{ - "domain": resp.Domain, - "message": resp.Message, - "local_target": localTarget, - }) - - return &ClientHandshakeResult{ - Domain: resp.Domain, - Message: resp.Message, - }, nil -} - -// writeHandshakeRequest 는 JSON 인코더를 사용해 handshakeRequest 를 세션으로 전송합니다. -func writeHandshakeRequest(sess Session, req handshakeRequest) error { - enc := json.NewEncoder(sess) - return enc.Encode(&req) -} - -// writeHandshakeResponse 는 JSON 인코더를 사용해 handshakeResponse 를 세션으로 전송합니다. -func writeHandshakeResponse(sess Session, resp handshakeResponse) error { - enc := json.NewEncoder(sess) - return enc.Encode(&resp) -} - -func stringTrimSpace(s string) string { - return strings.TrimSpace(s) -} diff --git a/internal/dtls/selfsigned.go b/internal/dtls/selfsigned.go deleted file mode 100644 index 73c0c9e..0000000 --- a/internal/dtls/selfsigned.go +++ /dev/null @@ -1,69 +0,0 @@ -package dtls - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "math/big" - "net" - "time" -) - -// NewSelfSignedLocalhostConfig 는 테스트용 self-signed TLS 설정을 생성합니다. -// -// - CN: "localhost" -// - DNS SAN: ["localhost"] -// - IP SAN: [127.0.0.1] -// - 유효기간: 생성 시점 기준 1년 -// -// DTLS, 일반 TLS 서버 모두에서 사용할 수 있으며, -// 서버 측에서는 Certificates 에 이 인증서를 넣어주고, -// 클라이언트 측에서는 debug 모드에서 InsecureSkipVerify 를 true 로 두어 -// 체인 검증을 스킵하는 방식으로 사용할 수 있습니다. -func NewSelfSignedLocalhostConfig() (*tls.Config, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, err - } - - serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) - if err != nil { - return nil, err - } - - notBefore := time.Now().Add(-1 * time.Hour) - notAfter := notBefore.Add(365 * 24 * time.Hour) - - template := &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{ - CommonName: "localhost", - }, - NotBefore: notBefore, - NotAfter: notAfter, - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - } - - derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) - if err != nil { - return nil, err - } - - tlsCert := tls.Certificate{ - Certificate: [][]byte{derBytes}, - PrivateKey: priv, - } - - return &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, - MinVersion: tls.VersionTLS12, - }, nil -} diff --git a/internal/dtls/transport_pion.go b/internal/dtls/transport_pion.go deleted file mode 100644 index cbe885d..0000000 --- a/internal/dtls/transport_pion.go +++ /dev/null @@ -1,58 +0,0 @@ -package dtls - -import ( - "crypto/tls" - "fmt" - "time" -) - -// PionServerConfig 는 DTLS 서버 리스너 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko) -// PionServerConfig keeps the old DTLS server listener configuration shape for compatibility. (en) -type PionServerConfig struct { - Addr string - TLSConfig *tls.Config -} - -// PionClientConfig 는 DTLS 클라이언트 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko) -// PionClientConfig keeps the old DTLS client configuration shape for compatibility. (en) -type PionClientConfig struct { - Addr string - TLSConfig *tls.Config - Timeout time.Duration -} - -// disabledServer 는 DTLS 전송이 비활성화되었음을 나타내는 더미 구현입니다. (ko) -// disabledServer is a dummy Server implementation indicating that DTLS transport is disabled. (en) -type disabledServer struct{} - -func (s *disabledServer) Accept() (Session, error) { - return nil, fmt.Errorf("dtls transport is disabled; use gRPC tunnel instead") -} - -func (s *disabledServer) Close() error { - 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{} -} diff --git a/internal/dtls/validator_dummy.go b/internal/dtls/validator_dummy.go deleted file mode 100644 index 516dbef..0000000 --- a/internal/dtls/validator_dummy.go +++ /dev/null @@ -1,33 +0,0 @@ -package dtls - -import ( - "context" - - "github.com/dalbodeule/hop-gate/internal/logging" -) - -// DomainValidator 는 handshake.go 에 정의된 인터페이스를 재노출합니다. -// (동일 패키지이므로 별도 선언 없이 사용하지만, 여기에 더미 구현을 둡니다.) - -// DummyDomainValidator 는 임시 개발용으로 모든 (domain, api_key) 조합을 허용하는 Validator 입니다. -// 실제 운영 환경에서는 ent + PostgreSQL 기반의 구현으로 교체해야 합니다. -type DummyDomainValidator struct { - Logger logging.Logger -} - -func (d DummyDomainValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error { - if d.Logger != nil { - d.Logger.Debug("dummy domain validator used (ALWAYS ALLOW)", logging.Fields{ - "domain": domain, - "client_api_key_masked": maskKey(clientAPIKey), - }) - } - return nil -} - -func maskKey(key string) string { - if len(key) <= 8 { - return "***" - } - return key[:4] + "..." + key[len(key)-4:] -} diff --git a/internal/errorpages/assets/errors.css b/internal/errorpages/assets/errors.css index 9c6e084..36fb5e0 100644 --- a/internal/errorpages/assets/errors.css +++ b/internal/errorpages/assets/errors.css @@ -1,2 +1,2 @@ -/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.container{width:100%}.contents{display:contents}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.min-h-screen{min-height:100vh}.w-\[240px\]{width:240px}.w-full{width:100%}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.text-center{text-align:center}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.uppercase{text-transform:uppercase}.opacity-90{opacity:.9}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.container{width:100%}.contents{display:contents}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.min-h-screen{min-height:100vh}.w-\[240px\]{width:240px}.w-full{width:100%}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.text-center{text-align:center}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.uppercase{text-transform:uppercase}.opacity-90{opacity:.9}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/internal/errorpages/errorpages.go b/internal/errorpages/errorpages.go index 54b1f54..f1bdb9a 100644 --- a/internal/errorpages/errorpages.go +++ b/internal/errorpages/errorpages.go @@ -10,8 +10,8 @@ import ( ) // StatusTLSHandshakeFailed is an HTTP-style status code representing -// a TLS/DTLS handshake failure (similar to Cloudflare 525). -// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525) +// a TLS tunnel handshake failure (similar to Cloudflare 525). +// TLS 터널 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525) const StatusTLSHandshakeFailed = 525 // StatusGatewayTimeout is an HTTP-style status code representing diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 8a9ae64..bd4cde5 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -8,15 +8,6 @@ import ( // Prometheus 기본 네임스페이스를 사용하며, 메트릭 이름에 hopgate_ 접두어를 붙입니다. var ( - // DTLS 핸드셰이크 총 횟수 (성공/실패 라벨 포함). - DTLSHandshakesTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "hopgate_dtls_handshakes_total", - Help: "Total number of DTLS handshakes, labeled by result.", - }, - []string{"result"}, // success, failure - ) - // HTTP/Proxy 엔드포인트를 통해 들어온 요청 수 (메서드/상태 코드 라벨 포함). HTTPRequestsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ @@ -42,7 +33,7 @@ var ( Name: "hopgate_proxy_errors_total", Help: "Total number of proxy-related errors, labeled by error type.", }, - []string{"type"}, // e.g. no_dtls_session, dtls_forward_failed, acme_http01_error + []string{"type"}, // e.g. no_tunnel_session, tunnel_forward_failed, acme_http01_error ) ) @@ -50,7 +41,6 @@ var ( // 서버 시작 시 한 번만 호출해야 합니다. func MustRegister() { prometheus.MustRegister( - DTLSHandshakesTotal, HTTPRequestsTotal, HTTPRequestDurationSeconds, ProxyErrorsTotal, diff --git a/internal/protocol/codec.go b/internal/protocol/codec.go deleted file mode 100644 index 5a3940f..0000000 --- a/internal/protocol/codec.go +++ /dev/null @@ -1,427 +0,0 @@ -package protocol - -import ( - "bufio" - "encoding/binary" - "encoding/json" - "fmt" - "io" - - protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb" - "google.golang.org/protobuf/proto" -) - -// defaultDecoderBufferSize 는 pion/dtls 가 복호화한 애플리케이션 데이터를 -// JSON 디코더가 안전하게 처리할 수 있도록 사용하는 버퍼 크기입니다. -// This matches existing 64KiB readers used around DTLS sessions (used by the JSON codec). -const defaultDecoderBufferSize = 64 * 1024 - -// dtlsReadBufferSize 는 pion/dtls 내부 버퍼 한계에 맞춘 읽기 버퍼 크기입니다. -// pion/dtls 의 UnpackDatagram 함수는 8KB (8,192 bytes) 의 기본 수신 버퍼를 사용합니다. -// DTLS는 UDP 기반이므로 한 번의 Read()에서 전체 datagram을 읽어야 하며, -// 이 크기를 초과하는 DTLS 레코드는 처리되지 않습니다. -// dtlsReadBufferSize matches the pion/dtls internal buffer limit. -// pion/dtls's UnpackDatagram function uses an 8KB (8,192 bytes) receive buffer. -// Since DTLS is UDP-based, the entire datagram must be read in a single Read() call, -// and DTLS records exceeding this size cannot be processed. -const dtlsReadBufferSize = 8 * 1024 // 8KB - -// maxProtoEnvelopeBytes 는 단일 Protobuf Envelope 의 최대 크기에 대한 보수적 상한입니다. -// 아직 하드 리미트로 사용하지는 않지만, 향후 방어적 체크에 사용할 수 있습니다. -const maxProtoEnvelopeBytes = 512 * 1024 // 512KiB, 충분히 여유 있는 값 - -// WireCodec 는 protocol.Envelope 의 직렬화/역직렬화를 추상화합니다. -// JSON, Protobuf, length-prefixed binary 등으로 교체할 때 이 인터페이스만 유지하면 됩니다. -type WireCodec interface { - Encode(w io.Writer, env *Envelope) error - Decode(r io.Reader, env *Envelope) error -} - -// jsonCodec 은 JSON 기반 WireCodec 구현입니다. -// JSON 직렬화를 계속 사용하고 싶을 때를 위해 남겨둡니다. -type jsonCodec struct{} - -// Encode 는 Envelope 를 JSON 으로 인코딩해 작성합니다. -// Encode encodes an Envelope as JSON to the given writer. -func (jsonCodec) Encode(w io.Writer, env *Envelope) error { - enc := json.NewEncoder(w) - return enc.Encode(env) -} - -// Decode 는 DTLS 세션에서 읽은 데이터를 JSON Envelope 로 디코딩합니다. -// pion/dtls 의 버퍼 특성 때문에, 충분히 큰 bufio.Reader 로 감싸서 사용합니다. -// Decode decodes an Envelope from JSON using a buffered reader on top of the DTLS session. -func (jsonCodec) Decode(r io.Reader, env *Envelope) error { - dec := json.NewDecoder(bufio.NewReaderSize(r, defaultDecoderBufferSize)) - return dec.Decode(env) -} - -// protobufCodec 은 Protobuf length-prefix framing 기반 WireCodec 구현입니다. -// 한 Envelope 당 [4바이트 big-endian 길이] [protobuf bytes] 형태로 인코딩합니다. -type protobufCodec struct{} - -// Encode 는 Envelope 를 Protobuf Envelope 로 변환한 뒤, length-prefix 프레이밍으로 기록합니다. -// DTLS는 UDP 기반이므로, length prefix와 protobuf 데이터를 단일 버퍼로 합쳐 하나의 Write로 전송합니다. -// Encode encodes an Envelope as a length-prefixed protobuf message. -// For DTLS (UDP-based), we combine the length prefix and protobuf data into a single buffer -// and send it with a single Write call to preserve message boundaries. -func (protobufCodec) Encode(w io.Writer, env *Envelope) error { - pbEnv, err := toProtoEnvelope(env) - if err != nil { - return err - } - - // Body/stream payload 하드 리밋: 4KiB (StreamChunkSize). - // HTTP 단일 Envelope 및 스트림 기반 프레임 모두에서 payload 가 이 값을 넘지 않도록 강제합니다. - // Enforce a 4KiB hard limit (StreamChunkSize) for HTTP bodies and stream payloads. - switch env.Type { - case MessageTypeHTTP: - if env.HTTPRequest != nil && len(env.HTTPRequest.Body) > int(StreamChunkSize) { - return fmt.Errorf("protobuf codec: http request body too large: %d bytes (max %d)", len(env.HTTPRequest.Body), StreamChunkSize) - } - if env.HTTPResponse != nil && len(env.HTTPResponse.Body) > int(StreamChunkSize) { - return fmt.Errorf("protobuf codec: http response body too large: %d bytes (max %d)", len(env.HTTPResponse.Body), StreamChunkSize) - } - case MessageTypeStreamData: - if env.StreamData != nil && len(env.StreamData.Data) > int(StreamChunkSize) { - return fmt.Errorf("protobuf codec: stream data payload too large: %d bytes (max %d)", len(env.StreamData.Data), StreamChunkSize) - } - } - - data, err := proto.Marshal(pbEnv) - if err != nil { - return fmt.Errorf("protobuf marshal envelope: %w", err) - } - if len(data) == 0 { - return fmt.Errorf("protobuf codec: empty marshaled envelope") - } - - if len(data) > int(^uint32(0)) { - return fmt.Errorf("protobuf codec: envelope too large: %d bytes", len(data)) - } - - // DTLS 환경에서는 length prefix와 protobuf 데이터를 단일 버퍼로 합쳐서 하나의 Write로 전송 - // For DTLS, combine length prefix and protobuf data into a single buffer - frame := make([]byte, 4+len(data)) - binary.BigEndian.PutUint32(frame[:4], uint32(len(data))) - copy(frame[4:], data) - - if _, err := w.Write(frame); err != nil { - return fmt.Errorf("protobuf codec: write frame: %w", err) - } - return nil -} - -// Decode 는 length-prefix 프레임에서 Protobuf Envelope 를 읽어들여 -// 내부 Envelope 구조체로 변환합니다. -// DTLS는 UDP 기반이므로, 한 번의 Read로 전체 데이터그램을 읽습니다. -// Decode reads a length-prefixed protobuf Envelope and converts it into the internal Envelope. -// For DTLS (UDP-based), we read the entire datagram in a single Read call. -func (protobufCodec) Decode(r io.Reader, env *Envelope) error { - // 1) 길이 prefix 4바이트를 정확히 읽는다. - header := make([]byte, 4) - if _, err := io.ReadFull(r, header); err != nil { - return fmt.Errorf("protobuf codec: read length prefix: %w", err) - } - - length := binary.BigEndian.Uint32(header) - if length == 0 { - return fmt.Errorf("protobuf codec: zero-length envelope") - } - if length > maxProtoEnvelopeBytes { - return fmt.Errorf("protobuf codec: envelope too large: %d bytes (max %d)", length, maxProtoEnvelopeBytes) - } - - // 2) payload 를 length 바이트만큼 정확히 읽는다. - payload := make([]byte, int(length)) - if _, err := io.ReadFull(r, payload); err != nil { - return fmt.Errorf("protobuf codec: read payload: %w", err) - } - - var pbEnv protocolpb.Envelope - if err := proto.Unmarshal(payload, &pbEnv); err != nil { - return fmt.Errorf("protobuf codec: unmarshal envelope: %w", err) - } - - return fromProtoEnvelope(&pbEnv, env) -} - -// DefaultCodec 은 현재 런타임에서 사용하는 기본 WireCodec 입니다. -// 현재는 Protobuf length-prefix 기반 codec 을 기본으로 사용합니다. -// 서버와 클라이언트가 모두 이 버전을 사용해야 wire-format 이 일치합니다. -var DefaultCodec WireCodec = protobufCodec{} - -// GetDTLSReadBufferSize 는 DTLS 세션 읽기에 사용할 버퍼 크기를 반환합니다. -// 이 값은 pion/dtls 내부 버퍼 한계(8KB)에 맞춰져 있습니다. -// GetDTLSReadBufferSize returns the buffer size to use for reading from DTLS sessions. -// This value is aligned with pion/dtls's internal buffer limit (8KB). -func GetDTLSReadBufferSize() int { - return dtlsReadBufferSize -} - -// toProtoEnvelope 는 내부 Envelope 구조체를 Protobuf Envelope 로 변환합니다. -// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다. -func toProtoEnvelope(env *Envelope) (*protocolpb.Envelope, error) { - switch env.Type { - case MessageTypeHTTP: - if env.HTTPRequest != nil { - req := env.HTTPRequest - pbReq := &protocolpb.Request{ - RequestId: req.RequestID, - ClientId: req.ClientID, - ServiceName: req.ServiceName, - Method: req.Method, - Url: req.URL, - Header: make(map[string]*protocolpb.HeaderValues, len(req.Header)), - Body: req.Body, - } - for k, vs := range req.Header { - hv := &protocolpb.HeaderValues{ - Values: append([]string(nil), vs...), - } - pbReq.Header[k] = hv - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_HttpRequest{ - HttpRequest: pbReq, - }, - }, nil - } - if env.HTTPResponse != nil { - resp := env.HTTPResponse - pbResp := &protocolpb.Response{ - RequestId: resp.RequestID, - Status: int32(resp.Status), - Header: make(map[string]*protocolpb.HeaderValues, len(resp.Header)), - Body: resp.Body, - Error: resp.Error, - } - for k, vs := range resp.Header { - hv := &protocolpb.HeaderValues{ - Values: append([]string(nil), vs...), - } - pbResp.Header[k] = hv - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_HttpResponse{ - HttpResponse: pbResp, - }, - }, nil - } - return nil, fmt.Errorf("protobuf codec: http envelope has neither request nor response") - case MessageTypeStreamOpen: - if env.StreamOpen == nil { - return nil, fmt.Errorf("protobuf codec: stream_open envelope missing payload") - } - so := env.StreamOpen - pbSO := &protocolpb.StreamOpen{ - Id: string(so.ID), - ServiceName: so.Service, - TargetAddr: so.TargetAddr, - Header: make(map[string]*protocolpb.HeaderValues, len(so.Header)), - } - for k, vs := range so.Header { - hv := &protocolpb.HeaderValues{ - Values: append([]string(nil), vs...), - } - pbSO.Header[k] = hv - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_StreamOpen{ - StreamOpen: pbSO, - }, - }, nil - case MessageTypeStreamData: - if env.StreamData == nil { - return nil, fmt.Errorf("protobuf codec: stream_data envelope missing payload") - } - sd := env.StreamData - pbSD := &protocolpb.StreamData{ - Id: string(sd.ID), - Seq: sd.Seq, - Data: sd.Data, - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_StreamData{ - StreamData: pbSD, - }, - }, nil - case MessageTypeStreamClose: - if env.StreamClose == nil { - return nil, fmt.Errorf("protobuf codec: stream_close envelope missing payload") - } - sc := env.StreamClose - pbSC := &protocolpb.StreamClose{ - Id: string(sc.ID), - Error: sc.Error, - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_StreamClose{ - StreamClose: pbSC, - }, - }, nil - case MessageTypeStreamAck: - if env.StreamAck == nil { - return nil, fmt.Errorf("protobuf codec: stream_ack envelope missing payload") - } - sa := env.StreamAck - pbSA := &protocolpb.StreamAck{ - Id: string(sa.ID), - AckSeq: sa.AckSeq, - LostSeqs: append([]uint64(nil), sa.LostSeqs...), - WindowSize: sa.WindowSize, - } - return &protocolpb.Envelope{ - Payload: &protocolpb.Envelope_StreamAck{ - StreamAck: pbSA, - }, - }, nil - default: - return nil, fmt.Errorf("protobuf codec: unsupported envelope type %q", env.Type) - } -} - -// fromProtoEnvelope 는 Protobuf Envelope 를 내부 Envelope 구조체로 변환합니다. -// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다. -func fromProtoEnvelope(pbEnv *protocolpb.Envelope, env *Envelope) error { - switch payload := pbEnv.Payload.(type) { - case *protocolpb.Envelope_HttpRequest: - req := payload.HttpRequest - if req == nil { - return fmt.Errorf("protobuf codec: http_request payload is nil") - } - hdr := make(map[string][]string, len(req.Header)) - for k, hv := range req.Header { - if hv == nil { - continue - } - hdr[k] = append([]string(nil), hv.Values...) - } - env.Type = MessageTypeHTTP - env.HTTPRequest = &Request{ - RequestID: req.RequestId, - ClientID: req.ClientId, - ServiceName: req.ServiceName, - Method: req.Method, - URL: req.Url, - Header: hdr, - Body: append([]byte(nil), req.Body...), - } - env.HTTPResponse = nil - env.StreamOpen = nil - env.StreamData = nil - env.StreamClose = nil - env.StreamAck = nil - return nil - - case *protocolpb.Envelope_HttpResponse: - resp := payload.HttpResponse - if resp == nil { - return fmt.Errorf("protobuf codec: http_response payload is nil") - } - hdr := make(map[string][]string, len(resp.Header)) - for k, hv := range resp.Header { - if hv == nil { - continue - } - hdr[k] = append([]string(nil), hv.Values...) - } - env.Type = MessageTypeHTTP - env.HTTPResponse = &Response{ - RequestID: resp.RequestId, - Status: int(resp.Status), - Header: hdr, - Body: append([]byte(nil), resp.Body...), - Error: resp.Error, - } - env.HTTPRequest = nil - env.StreamOpen = nil - env.StreamData = nil - env.StreamClose = nil - env.StreamAck = nil - return nil - - case *protocolpb.Envelope_StreamOpen: - so := payload.StreamOpen - if so == nil { - return fmt.Errorf("protobuf codec: stream_open payload is nil") - } - hdr := make(map[string][]string, len(so.Header)) - for k, hv := range so.Header { - if hv == nil { - continue - } - hdr[k] = append([]string(nil), hv.Values...) - } - env.Type = MessageTypeStreamOpen - env.StreamOpen = &StreamOpen{ - ID: StreamID(so.Id), - Service: so.ServiceName, - TargetAddr: so.TargetAddr, - Header: hdr, - } - env.StreamData = nil - env.StreamClose = nil - env.StreamAck = nil - env.HTTPRequest = nil - env.HTTPResponse = nil - return nil - - case *protocolpb.Envelope_StreamData: - sd := payload.StreamData - if sd == nil { - return fmt.Errorf("protobuf codec: stream_data payload is nil") - } - env.Type = MessageTypeStreamData - env.StreamData = &StreamData{ - ID: StreamID(sd.Id), - Seq: sd.Seq, - Data: append([]byte(nil), sd.Data...), - } - env.StreamOpen = nil - env.StreamClose = nil - env.StreamAck = nil - env.HTTPRequest = nil - env.HTTPResponse = nil - return nil - - case *protocolpb.Envelope_StreamClose: - sc := payload.StreamClose - if sc == nil { - return fmt.Errorf("protobuf codec: stream_close payload is nil") - } - env.Type = MessageTypeStreamClose - env.StreamClose = &StreamClose{ - ID: StreamID(sc.Id), - Error: sc.Error, - } - env.StreamOpen = nil - env.StreamData = nil - env.StreamAck = nil - env.HTTPRequest = nil - env.HTTPResponse = nil - return nil - - case *protocolpb.Envelope_StreamAck: - sa := payload.StreamAck - if sa == nil { - return fmt.Errorf("protobuf codec: stream_ack payload is nil") - } - env.Type = MessageTypeStreamAck - env.StreamAck = &StreamAck{ - ID: StreamID(sa.Id), - AckSeq: sa.AckSeq, - LostSeqs: append([]uint64(nil), sa.LostSeqs...), - WindowSize: sa.WindowSize, - } - env.StreamOpen = nil - env.StreamData = nil - env.StreamClose = nil - env.HTTPRequest = nil - env.HTTPResponse = nil - return nil - - default: - return fmt.Errorf("protobuf codec: unsupported payload type %T", payload) - } -} diff --git a/internal/protocol/codec_test.go b/internal/protocol/codec_test.go deleted file mode 100644 index 6ca0fff..0000000 --- a/internal/protocol/codec_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package protocol - -import ( - "bufio" - "bytes" - "io" - "testing" -) - -// mockDatagramConn simulates a datagram-based connection (like DTLS over UDP) -// where each Write sends a separate message and each Read receives a complete message. -// This mock verifies the FIXED behavior where the codec properly handles message boundaries. -type mockDatagramConn struct { - messages [][]byte - readIdx int -} - -func newMockDatagramConn() *mockDatagramConn { - return &mockDatagramConn{ - messages: make([][]byte, 0), - } -} - -func (m *mockDatagramConn) Write(p []byte) (n int, err error) { - // Simulate datagram behavior: each Write is a separate message - msg := make([]byte, len(p)) - copy(msg, p) - m.messages = append(m.messages, msg) - return len(p), nil -} - -func (m *mockDatagramConn) Read(p []byte) (n int, err error) { - // Simulate datagram behavior: each Read returns a complete message - if m.readIdx >= len(m.messages) { - return 0, io.EOF - } - msg := m.messages[m.readIdx] - m.readIdx++ - if len(p) < len(msg) { - return 0, io.ErrShortBuffer - } - copy(p, msg) - return len(msg), nil -} - -// TestProtobufCodecDatagramBehavior tests that the protobuf codec works correctly -// with datagram-based transports (like DTLS over UDP) where message boundaries are preserved. -func TestProtobufCodecDatagramBehavior(t *testing.T) { - codec := protobufCodec{} - conn := newMockDatagramConn() - - // Create a test envelope - testEnv := &Envelope{ - Type: MessageTypeHTTP, - HTTPRequest: &Request{ - RequestID: "test-req-123", - ClientID: "client-1", - ServiceName: "test-service", - Method: "GET", - URL: "/test/path", - Header: map[string][]string{ - "User-Agent": {"test-client"}, - }, - Body: []byte("test body content"), - }, - } - - // Encode the envelope - if err := codec.Encode(conn, testEnv); err != nil { - t.Fatalf("Failed to encode envelope: %v", err) - } - - // Verify that exactly one message was written (length prefix + data in single Write) - if len(conn.messages) != 1 { - t.Fatalf("Expected 1 message to be written, got %d", len(conn.messages)) - } - - // Verify the message structure: [4-byte length][protobuf data] - msg := conn.messages[0] - if len(msg) < 4 { - t.Fatalf("Message too short: %d bytes", len(msg)) - } - - // Decode the envelope using a buffered reader (as we do in actual code) - // to handle datagram-based reading properly - reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize()) - var decodedEnv Envelope - if err := codec.Decode(reader, &decodedEnv); err != nil { - t.Fatalf("Failed to decode envelope: %v", err) - } - - // Verify the decoded envelope matches the original - if decodedEnv.Type != testEnv.Type { - t.Errorf("Type mismatch: got %v, want %v", decodedEnv.Type, testEnv.Type) - } - if decodedEnv.HTTPRequest == nil { - t.Fatal("HTTPRequest is nil after decode") - } - if decodedEnv.HTTPRequest.RequestID != testEnv.HTTPRequest.RequestID { - t.Errorf("RequestID mismatch: got %v, want %v", decodedEnv.HTTPRequest.RequestID, testEnv.HTTPRequest.RequestID) - } - if decodedEnv.HTTPRequest.Method != testEnv.HTTPRequest.Method { - t.Errorf("Method mismatch: got %v, want %v", decodedEnv.HTTPRequest.Method, testEnv.HTTPRequest.Method) - } - if decodedEnv.HTTPRequest.URL != testEnv.HTTPRequest.URL { - t.Errorf("URL mismatch: got %v, want %v", decodedEnv.HTTPRequest.URL, testEnv.HTTPRequest.URL) - } - if !bytes.Equal(decodedEnv.HTTPRequest.Body, testEnv.HTTPRequest.Body) { - t.Errorf("Body mismatch: got %v, want %v", decodedEnv.HTTPRequest.Body, testEnv.HTTPRequest.Body) - } -} - -// TestProtobufCodecStreamData tests encoding/decoding of StreamData messages -func TestProtobufCodecStreamData(t *testing.T) { - codec := protobufCodec{} - conn := newMockDatagramConn() - - // Create a StreamData envelope - testEnv := &Envelope{ - Type: MessageTypeStreamData, - StreamData: &StreamData{ - ID: StreamID("stream-123"), - Seq: 42, - Data: []byte("stream data payload"), - }, - } - - // Encode - if err := codec.Encode(conn, testEnv); err != nil { - t.Fatalf("Failed to encode StreamData: %v", err) - } - - // Verify single message - if len(conn.messages) != 1 { - t.Fatalf("Expected 1 message, got %d", len(conn.messages)) - } - - // Decode using a buffered reader (as we do in actual code) - reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize()) - var decodedEnv Envelope - if err := codec.Decode(reader, &decodedEnv); err != nil { - t.Fatalf("Failed to decode StreamData: %v", err) - } - - // Verify - if decodedEnv.Type != MessageTypeStreamData { - t.Errorf("Type mismatch: got %v, want %v", decodedEnv.Type, MessageTypeStreamData) - } - if decodedEnv.StreamData == nil { - t.Fatal("StreamData is nil") - } - if decodedEnv.StreamData.ID != testEnv.StreamData.ID { - t.Errorf("StreamID mismatch: got %v, want %v", decodedEnv.StreamData.ID, testEnv.StreamData.ID) - } - if decodedEnv.StreamData.Seq != testEnv.StreamData.Seq { - t.Errorf("Seq mismatch: got %v, want %v", decodedEnv.StreamData.Seq, testEnv.StreamData.Seq) - } - if !bytes.Equal(decodedEnv.StreamData.Data, testEnv.StreamData.Data) { - t.Errorf("Data mismatch: got %v, want %v", decodedEnv.StreamData.Data, testEnv.StreamData.Data) - } -} - -// TestProtobufCodecMultipleMessages tests encoding/decoding multiple messages -func TestProtobufCodecMultipleMessages(t *testing.T) { - codec := protobufCodec{} - conn := newMockDatagramConn() - - // Create multiple test envelopes - envelopes := []*Envelope{ - { - Type: MessageTypeStreamOpen, - StreamOpen: &StreamOpen{ - ID: StreamID("stream-1"), - Service: "test-service", - TargetAddr: "127.0.0.1:8080", - }, - }, - { - Type: MessageTypeStreamData, - StreamData: &StreamData{ - ID: StreamID("stream-1"), - Seq: 1, - Data: []byte("first chunk"), - }, - }, - { - Type: MessageTypeStreamData, - StreamData: &StreamData{ - ID: StreamID("stream-1"), - Seq: 2, - Data: []byte("second chunk"), - }, - }, - { - Type: MessageTypeStreamClose, - StreamClose: &StreamClose{ - ID: StreamID("stream-1"), - Error: "", - }, - }, - } - - // Encode all messages - for i, env := range envelopes { - if err := codec.Encode(conn, env); err != nil { - t.Fatalf("Failed to encode message %d: %v", i, err) - } - } - - // Verify that each encode produced exactly one message - if len(conn.messages) != len(envelopes) { - t.Fatalf("Expected %d messages, got %d", len(envelopes), len(conn.messages)) - } - - // Decode and verify all messages using a buffered reader (as we do in actual code) - reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize()) - for i := 0; i < len(envelopes); i++ { - var decoded Envelope - if err := codec.Decode(reader, &decoded); err != nil { - t.Fatalf("Failed to decode message %d: %v", i, err) - } - if decoded.Type != envelopes[i].Type { - t.Errorf("Message %d type mismatch: got %v, want %v", i, decoded.Type, envelopes[i].Type) - } - } -} diff --git a/internal/protocol/hopgate_stream.proto b/internal/protocol/hopgate_stream.proto deleted file mode 100644 index c16b7c8..0000000 --- a/internal/protocol/hopgate_stream.proto +++ /dev/null @@ -1,103 +0,0 @@ -syntax = "proto3"; - -package hopgate.protocol.v1; - -option go_package = "internal/protocol/pb;pb"; - -// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다. -// HeaderValues wraps multiple header values for a single HTTP header key. -message HeaderValues { - repeated string values = 1; -} - -// Request 는 DTLS 터널 위에서 교환되는 HTTP 요청을 표현합니다. -// This mirrors internal/protocol.Request. -message Request { - string request_id = 1; - string client_id = 2; // optional client identifier - string service_name = 3; // logical service name on the client side - - string method = 4; - string url = 5; - - // HTTP header: map of key -> multiple values. - map header = 6; - - // Raw HTTP body bytes. - bytes body = 7; -} - -// Response 는 DTLS 터널 위에서 교환되는 HTTP 응답을 표현합니다. -// This mirrors internal/protocol.Response. -message Response { - string request_id = 1; - int32 status = 2; - - // HTTP header. - map header = 3; - - // Raw HTTP body bytes. - bytes body = 4; - - // Optional error description when tunneling fails. - string error = 5; -} - -// StreamOpen 은 새로운 스트림(HTTP 요청/응답, WebSocket 등)을 여는 메시지입니다. -// This represents opening a new stream (HTTP request/response, WebSocket, etc.). -message StreamOpen { - string id = 1; // StreamID (text form) - - // Which logical service / local target to use on the client side. - string service_name = 2; - string target_addr = 3; // e.g. "127.0.0.1:8080" - - // Initial HTTP-like headers (including Upgrade, etc.). - map header = 4; -} - -// StreamData 는 이미 열린 스트림에 대한 단방향 데이터 프레임입니다. -// This is a unidirectional data frame on an already-open stream. -message StreamData { - string id = 1; // StreamID - uint64 seq = 2; // per-stream sequence number starting from 0 - bytes data = 3; -} - -// StreamAck 는 StreamData 에 대한 ACK/NACK 및 선택적 재전송 힌트를 전달합니다. -// This conveys ACK/NACK and optional retransmission hints for StreamData. -message StreamAck { - string id = 1; - - // Last contiguously received sequence number (starting from 0). - uint64 ack_seq = 2; - - // Additional missing sequence numbers beyond ack_seq (optional). - repeated uint64 lost_seqs = 3; - - // Optional receive window size hint. - uint32 window_size = 4; -} - -// StreamClose 는 스트림 종료(정상/에러)를 알립니다. -// This indicates normal or error termination of a stream. -message StreamClose { - string id = 1; - string error = 2; // empty means normal close -} - -// 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. -message Envelope { - oneof payload { - Request http_request = 1; - Response http_response = 2; - - StreamOpen stream_open = 3; - StreamData stream_data = 4; - StreamClose stream_close = 5; - StreamAck stream_ack = 6; - } -} \ No newline at end of file diff --git a/internal/protocol/pb/hopgate_stream.pb.go b/internal/protocol/pb/hopgate_stream.pb.go deleted file mode 100644 index 5654c5d..0000000 --- a/internal/protocol/pb/hopgate_stream.pb.go +++ /dev/null @@ -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 pb - -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\x19Z\x17internal/protocol/pb;pbb\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 -} diff --git a/internal/protocol/pb/hopgate_stream_grpc.go b/internal/protocol/pb/hopgate_stream_grpc.go deleted file mode 100644 index 36e403e..0000000 --- a/internal/protocol/pb/hopgate_stream_grpc.go +++ /dev/null @@ -1,119 +0,0 @@ -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", -} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go deleted file mode 100644 index 5805950..0000000 --- a/internal/protocol/protocol.go +++ /dev/null @@ -1,147 +0,0 @@ -package protocol - -// Request 는 서버-클라이언트 간에 전달되는 HTTP 요청을 표현합니다. -// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다. -type Request struct { - RequestID string - ClientID string // 대상 클라이언트 식별자 - ServiceName string // 클라이언트 내부 서비스 이름 - - Method string - URL string - Header map[string][]string - Body []byte -} - -// Response 는 서버-클라이언트 간에 전달되는 HTTP 응답을 표현합니다. -// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다. -type Response struct { - RequestID string - Status int - Header map[string][]string - Body []byte - Error string // 에러 발생 시 설명 메시지 -} - -// --- 확장 가능 DTLS 메시지 Envelope 및 스트림 구조체 --- -// -// WebSocket/TCP 스트림 터널링을 지원하기 위해, 단일 HTTP 요청/응답 외에도 -// 스트림 기반 메시지를 운반할 수 있는 Envelope 타입을 정의합니다. -// 현재 구현에서는 아직 사용하지 않으며, 향후 단계적으로 적용할 예정입니다. - -// MessageType 은 DTLS 위에서 교환되는 상위 레벨 메시지 종류를 나타냅니다. -type MessageType string - -// StreamChunkSize 는 스트림 터널링 시 단일 StreamData 프레임에 담을 최대 payload 크기입니다. -// 현재 구현에서는 4KiB 로 고정하여 DTLS/UDP MTU 한계를 여유 있게 피하도록 합니다. -// StreamChunkSize is the maximum payload size per StreamData frame (4KiB). -const StreamChunkSize = 4 * 1024 - -const ( - // MessageTypeHTTP 는 기존 단일 HTTP 요청/응답 메시지를 의미합니다. - // 이 경우 HTTPRequest / HTTPResponse 필드를 사용합니다. - MessageTypeHTTP MessageType = "http" - - // MessageTypeStreamOpen 은 새로운 스트림(TCP/WebSocket 등)의 오픈을 의미합니다. - MessageTypeStreamOpen MessageType = "stream_open" - - // MessageTypeStreamData 는 열린 스트림에 대한 양방향 데이터 프레임을 의미합니다. - // HTTP 바디 chunk 를 비롯한 실제 payload 는 이 타입을 통해 전송됩니다. - // Stream data frames for an already-opened stream (HTTP body chunks, etc.). - MessageTypeStreamData MessageType = "stream_data" - - // MessageTypeStreamClose 는 스트림 종료(정상/에러)를 의미합니다. - // Normal or error-termination of a stream. - MessageTypeStreamClose MessageType = "stream_close" - - // MessageTypeStreamAck 는 스트림 데이터 프레임에 대한 ACK/NACK 및 재전송 힌트를 전달합니다. - // Stream-level ACK/NACK frames for selective retransmission hints. - MessageTypeStreamAck MessageType = "stream_ack" -) - -// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다. -// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다. -type Envelope struct { - Type MessageType `json:"type"` - - // HTTP 1회성 요청/응답 (기존 터널링 경로) - HTTPRequest *Request `json:"http_request,omitempty"` - HTTPResponse *Response `json:"http_response,omitempty"` - - // 스트림 기반 메시지 (WebSocket/TCP 터널용) - StreamOpen *StreamOpen `json:"stream_open,omitempty"` - StreamData *StreamData `json:"stream_data,omitempty"` - StreamClose *StreamClose `json:"stream_close,omitempty"` - - // 스트림 제어 메시지 (ACK/NACK, 재전송 힌트 등) - // Stream-level control messages (ACK/NACK, retransmission hints, etc.). - StreamAck *StreamAck `json:"stream_ack,omitempty"` -} - -// StreamID 는 스트림(예: 특정 WebSocket 연결 또는 TCP 커넥션)을 구분하기 위한 식별자입니다. -type StreamID string - -// HTTP-over-stream 터널링에서 사용되는 pseudo-header 키 상수입니다. -// These pseudo-header keys are used when tunneling HTTP over the stream protocol. -const ( - HeaderKeyMethod = "X-HopGate-Method" - HeaderKeyURL = "X-HopGate-URL" - HeaderKeyHost = "X-HopGate-Host" - HeaderKeyStatus = "X-HopGate-Status" -) - -// StreamOpen 은 새로운 스트림을 여는 요청을 나타냅니다. -type StreamOpen struct { - ID StreamID `json:"id"` - - // Service / TargetAddr 는 클라이언트 측에서 어느 로컬 서비스로 연결해야 하는지를 나타냅니다. - // 최소 구현에서는 LocalTarget 하나만 사용해도 되며, 추후 서비스별로 확장 가능합니다. - Service string `json:"service_name,omitempty"` - TargetAddr string `json:"target_addr,omitempty"` // 예: "127.0.0.1:8080" - Header map[string][]string `json:"header,omitempty"` // 초기 HTTP 헤더(Upgrade 포함) 전달용 -} - -// StreamData 는 이미 열린 스트림에 대해 한 방향으로 전송되는 데이터 프레임을 표현합니다. -// DTLS/UDP 특성상 손실/중복/순서 뒤바뀜을 감지하고 재전송할 수 있도록 -// 각 스트림 내에서 0부터 시작하는 시퀀스 번호(Seq)를 포함합니다. -// -// StreamData represents a unidirectional data frame on an already-opened stream. -// To support loss/duplication/reordering detection and retransmission over DTLS/UDP, -// it carries a per-stream sequence number (Seq) starting from 0. -type StreamData struct { - ID StreamID `json:"id"` - Seq uint64 `json:"seq"` - Data []byte `json:"data"` -} - -// StreamAck 는 스트림 데이터 프레임에 대한 ACK/NACK 및 선택적 재전송 요청 정보를 전달합니다. -// AckSeq 는 수신 측에서 "연속적으로" 수신 완료한 마지막 Seq 를 의미하며, -// LostSeqs 는 그 이후 구간에서 누락된 시퀀스 번호(선택적)를 나타냅니다. -// -// StreamAck conveys ACK/NACK and optional retransmission hints for stream data frames. -// AckSeq denotes the last sequence number received contiguously by the receiver, -// while LostSeqs can list additional missing sequence numbers beyond AckSeq. -type StreamAck struct { - ID StreamID `json:"id"` - - // AckSeq 는 수신 측에서 0부터 시작해 연속으로 수신 완료한 마지막 Seq 입니다. - // AckSeq is the last contiguously received sequence number starting from 0. - AckSeq uint64 `json:"ack_seq"` - - // LostSeqs 는 AckSeq 이후 구간에서 누락된 시퀀스 번호 목록입니다(선택). - // 이 필드는 선택적 selective retransmission 힌트를 제공하기 위해 사용됩니다. - // - // LostSeqs is an optional list of missing sequence numbers beyond AckSeq, - // used as a hint for selective retransmission. - LostSeqs []uint64 `json:"lost_seqs,omitempty"` - - // WindowSize 는 수신 측이 허용 가능한 in-flight 프레임 수를 나타내는 선택적 힌트입니다. - // WindowSize is an optional hint for the allowed number of in-flight frames. - WindowSize uint32 `json:"window_size,omitempty"` -} - -// StreamClose 는 스트림 종료를 알리는 메시지입니다. -type StreamClose struct { - ID StreamID `json:"id"` - Error string `json:"error,omitempty"` // 비워두면 정상 종료로 해석 -} diff --git a/internal/proxy/client.go b/internal/proxy/client.go deleted file mode 100644 index 91108e2..0000000 --- a/internal/proxy/client.go +++ /dev/null @@ -1,1097 +0,0 @@ -package proxy - -import ( - "bufio" - "bytes" - "context" - "fmt" - "io" - "net" - "net/http" - "net/url" - "sort" - "strconv" - "sync" - "time" - - "github.com/dalbodeule/hop-gate/internal/logging" - "github.com/dalbodeule/hop-gate/internal/protocol" -) - -// ClientProxy 는 서버로부터 받은 요청을 로컬 HTTP 서비스로 전달하는 클라이언트 측 프록시입니다. (ko) -// ClientProxy forwards requests from the server to local HTTP services. (en) -type ClientProxy struct { - HTTPClient *http.Client - Logger logging.Logger - LocalTarget string // e.g. "127.0.0.1:8080" - - sendersMu sync.Mutex - streamSenders map[protocol.StreamID]*streamSender -} - -// NewClientProxy 는 기본 HTTP 클라이언트 및 로거를 사용해 ClientProxy 를 생성합니다. (ko) -// NewClientProxy creates a ClientProxy with a default HTTP client and logger. (en) -func NewClientProxy(logger logging.Logger, localTarget string) *ClientProxy { - if logger == nil { - logger = logging.NewStdJSONLogger("client_proxy") - } - return &ClientProxy{ - 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, - }, - }, - Logger: logger.With(logging.Fields{"component": "client_proxy"}), - LocalTarget: localTarget, - streamSenders: make(map[protocol.StreamID]*streamSender), - } -} - -// StartLoop 는 DTLS 세션에서 protocol.Envelope 를 읽고, HTTP/스트림 요청의 경우 로컬 HTTP 요청을 수행한 뒤 -// protocol.Envelope(HTTP/스트림 응답 포함)을 다시 세션으로 쓰는 루프를 실행합니다. (ko) -// StartLoop reads protocol.Envelope messages from the DTLS session; for HTTP/stream -// messages it performs local HTTP requests and writes back responses over the DTLS -// tunnel. (en) -type streamSender struct { - mu sync.Mutex - outstanding map[uint64][]byte -} - -func newStreamSender() *streamSender { - return &streamSender{ - outstanding: make(map[uint64][]byte), - } -} - -func (s *streamSender) register(seq uint64, data []byte) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.outstanding == nil { - s.outstanding = make(map[uint64][]byte) - } - buf := make([]byte, len(data)) - copy(buf, data) - s.outstanding[seq] = buf -} - -func (s *streamSender) handleAck(ack *protocol.StreamAck) map[uint64][]byte { - s.mu.Lock() - defer s.mu.Unlock() - - if s.outstanding == nil { - return nil - } - - // 연속 수신 완료 구간(seq <= AckSeq)은 outstanding 에서 제거합니다. - for seq := range s.outstanding { - if seq <= ack.AckSeq { - delete(s.outstanding, seq) - } - } - - // LostSeqs 가 비어 있으면 재전송할 것이 없습니다. - if len(ack.LostSeqs) == 0 { - return nil - } - - // LostSeqs 에 포함된 시퀀스 중, 아직 outstanding 에 남아 있는 것들만 재전송 대상으로 선택합니다. - lost := make(map[uint64][]byte, len(ack.LostSeqs)) - for _, seq := range ack.LostSeqs { - if data, ok := s.outstanding[seq]; ok { - buf := make([]byte, len(data)) - copy(buf, data) - lost[seq] = buf - } - } - return lost -} - -// streamReceiver 는 단일 스트림(ID)에 대한 클라이언트 측 수신 상태와 -// 로컬 HTTP 매핑을 담당하는 per-stream 구조체 설계입니다. (ko) -// streamReceiver is the per-stream receiver that owns client-side RX state -// and local HTTP mapping for a single stream ID. (en) -// -// 3.3B.2 설계 포인트: -// - 중앙 readLoop(StartLoop)는 DTLS 세션에서 Envelope 만 읽고, -// streamReceiver.inCh 로 `StreamOpen/StreamData/StreamClose` 를 전달합니다. -// - streamReceiver 는 자신에게 전달된 Envelope 들만 사용해 -// - 수신 ARQ(expectedSeq/received/lost) 를 관리하고, -// - HTTP 요청/응답을 구성해 역방향 StreamOpen/StreamData/StreamClose 를 전송합니다. -// - 실제 run 로직 및 StartLoop 와의 통합은 3.3B.3 단계에서 구현할 예정입니다. -type streamReceiver struct { - // 이 수신기가 담당하는 스트림 ID. - id protocol.StreamID - - // 수신 ARQ 상태: per-stream 시퀀스 및 out-of-order 버퍼/누락 집합. (ko) - // Receive-side ARQ state: per-stream sequence and out-of-order/lost sets. (en) - expectedSeq uint64 - received map[uint64][]byte - lost map[uint64]struct{} - - // 중앙 readLoop → per-stream goroutine 으로 전달되는 입력 채널. (ko) - // Input channel for envelopes dispatched from the central readLoop. (en) - inCh chan *protocol.Envelope - - // 세션(write 방향) 및 직렬화 codec / 로깅 핸들. (ko) - // Session (write side only), wire codec and logging handles. (en) - sess io.ReadWriter - codec protocol.WireCodec - logger logging.Logger - - // 로컬 HTTP 클라이언트 및 타깃 주소 정보. (ko) - // Local HTTP client and target information. (en) - HTTPClient *http.Client - LocalTarget string -} - -// newStreamReceiver 는 단일 스트림 ID 에 대한 수신 상태/HTTP 매핑을 담당하는 -// streamReceiver 인스턴스를 초기화합니다. (ko) -// newStreamReceiver initializes a streamReceiver for a single stream ID. (en) -func newStreamReceiver( - id protocol.StreamID, - sess io.ReadWriter, - codec protocol.WireCodec, - logger logging.Logger, - httpClient *http.Client, - localTarget string, -) *streamReceiver { - if codec == nil { - codec = protocol.DefaultCodec - } - return &streamReceiver{ - id: id, - expectedSeq: 0, - received: make(map[uint64][]byte), - lost: make(map[uint64]struct{}), - inCh: make(chan *protocol.Envelope, 16), - sess: sess, - codec: codec, - logger: logger, - HTTPClient: httpClient, - LocalTarget: localTarget, - } -} - -// run 은 단일 스트림에 대해 서버→클라이언트 방향 프레임을 처리하고, -// 로컬 HTTP 요청/응답을 수행한 뒤, 클라이언트→서버 방향 스트림 응답을 -// 전송하는 수명주기 전담 루프입니다. (ko) -// run is the per-stream lifecycle loop that consumes inbound frames, -// performs the local HTTP request/response, and sends the reverse stream -// back to the server. (en) -func (r *streamReceiver) run(ctx context.Context, so *protocol.StreamOpen, sender *streamSender) error { - codec := r.codec - if codec == nil { - codec = protocol.DefaultCodec - } - log := r.logger - if log == nil { - log = logging.NewStdJSONLogger("client_proxy_stream_receiver") - } - - streamID := r.id - - // Pseudo-header 에서 HTTP 메타데이터를 추출합니다. (ko) - // Extract HTTP metadata from pseudo-headers. (en) - method := firstHeaderValue(so.Header, protocol.HeaderKeyMethod, http.MethodGet) - urlStr := firstHeaderValue(so.Header, protocol.HeaderKeyURL, "/") - _ = firstHeaderValue(so.Header, protocol.HeaderKeyHost, "") - - if r.LocalTarget == "" { - return fmt.Errorf("local target is empty") - } - - u, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("parse url from stream_open: %w", err) - } - u.Scheme = "http" - u.Host = r.LocalTarget - - // 로컬 HTTP 요청용 헤더 맵을 생성하면서 pseudo-header 는 제거합니다. (ko) - // Build local HTTP header map while stripping pseudo-headers. (en) - httpHeader := make(http.Header, len(so.Header)) - for k, vs := range so.Header { - if k == protocol.HeaderKeyMethod || - k == protocol.HeaderKeyURL || - k == protocol.HeaderKeyHost || - k == protocol.HeaderKeyStatus { - continue - } - for _, v := range vs { - httpHeader.Add(k, v) - } - } - - // 요청 바디를 StreamData/StreamClose 프레임에서 모두 읽어 메모리에 적재합니다. (ko) - // Read the entire request body from StreamData/StreamClose frames into memory. (en) - // - // 동시에 수신 측 ARQ 상태(expectedSeq / out-of-order 버퍼 / LostSeqs)를 관리하고 - // StreamAck 를 전송해 선택적 재전송(Selective Retransmission)을 유도합니다. - var bodyBuf bytes.Buffer - const maxLostReport = 32 - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case env, ok := <-r.inCh: - if !ok { - return fmt.Errorf("stream receiver channel closed before stream_close") - } - - switch env.Type { - case protocol.MessageTypeStreamData: - sd := env.StreamData - if sd == nil { - return fmt.Errorf("stream_data payload is nil") - } - if sd.ID != streamID { - return fmt.Errorf("stream_data for unexpected stream id %q (expected %q)", sd.ID, streamID) - } - - // 수신 측 ARQ: Seq 에 따라 분기 - switch { - case sd.Seq == r.expectedSeq: - // 기대하던 순서의 프레임: 바로 bodyBuf 에 기록하고, 이후 버퍼된 연속 프레임도 flush. - if len(sd.Data) > 0 { - if _, err := bodyBuf.Write(sd.Data); err != nil { - return fmt.Errorf("buffer stream_data: %w", err) - } - } - r.expectedSeq++ - for { - data, ok := r.received[r.expectedSeq] - if !ok { - break - } - if len(data) > 0 { - if _, err := bodyBuf.Write(data); err != nil { - return fmt.Errorf("buffer reordered stream_data: %w", err) - } - } - delete(r.received, r.expectedSeq) - delete(r.lost, r.expectedSeq) - r.expectedSeq++ - } - - // AckSeq 이전 구간의 lost 항목 정리 - for seq := range r.lost { - if seq < r.expectedSeq { - delete(r.lost, seq) - } - } - - case sd.Seq > r.expectedSeq: - // 앞선 일부 Seq 들이 누락된 상태: 현재 프레임을 버퍼링하고 missing seq 들을 lost 에 추가. - if len(sd.Data) > 0 { - buf := make([]byte, len(sd.Data)) - copy(buf, sd.Data) - r.received[sd.Seq] = buf - } - for seq := r.expectedSeq; seq < sd.Seq && len(r.lost) < maxLostReport; seq++ { - if _, ok := r.lost[seq]; !ok { - r.lost[seq] = struct{}{} - } - } - - default: - // sd.Seq < expectedSeq 인 경우: 이미 처리했거나 Ack 로 커버된 프레임 → 무시. - } - - // 수신 측 StreamAck 전송: - // - AckSeq: 0부터 시작해 연속으로 수신 완료한 마지막 시퀀스 (expectedSeq-1) - // - LostSeqs: 현재 윈도우 내에서 누락된 시퀀스 중 상한 개수(maxLostReport)까지만 포함 - var ackSeq uint64 - if r.expectedSeq == 0 { - ackSeq = 0 - } else { - ackSeq = r.expectedSeq - 1 - } - - lostSeqs := make([]uint64, 0, len(r.lost)) - for seq := range r.lost { - if seq >= r.expectedSeq { - lostSeqs = append(lostSeqs, seq) - } - } - if len(lostSeqs) > 0 { - sort.Slice(lostSeqs, func(i, j int) bool { return lostSeqs[i] < lostSeqs[j] }) - if len(lostSeqs) > maxLostReport { - lostSeqs = lostSeqs[:maxLostReport] - } - } - - ackEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamAck, - StreamAck: &protocol.StreamAck{ - ID: streamID, - AckSeq: ackSeq, - LostSeqs: lostSeqs, - }, - } - if err := codec.Encode(r.sess, &ackEnv); err != nil { - return fmt.Errorf("send stream ack: %w", err) - } - - case protocol.MessageTypeStreamClose: - sc := env.StreamClose - if sc == nil { - return fmt.Errorf("stream_close payload is nil") - } - if sc.ID != streamID { - return fmt.Errorf("stream_close for unexpected stream id %q (expected %q)", sc.ID, streamID) - } - // sc.Error 는 최소 구현에서는 로컬 요청 에러와 별도로 취급하지 않습니다. (ko) - // For the minimal implementation we do not surface sc.Error here. (en) - goto haveBody - - default: - return fmt.Errorf("unexpected envelope type %q while reading stream request body", env.Type) - } - } - } - -haveBody: - bodyBytes := bodyBuf.Bytes() - - // 로컬 HTTP 요청 생성 (stream 기반 요청을 실제 HTTP 요청으로 변환). (ko) - // Build the local HTTP request from the stream-based metadata and body. (en) - req, err := http.NewRequestWithContext(ctx, method, u.String(), nil) - if err != nil { - return fmt.Errorf("create http request from stream: %w", err) - } - if len(bodyBytes) > 0 { - buf := bytes.NewReader(bodyBytes) - req.Body = io.NopCloser(buf) - req.ContentLength = int64(len(bodyBytes)) - } - req.Header = httpHeader - - start := time.Now() - logReq := log.With(logging.Fields{ - "request_id": string(streamID), - "service": so.Service, - "method": method, - "url": urlStr, - "stream_id": string(streamID), - "local_target": r.LocalTarget, - }) - logReq.Info("received stream_open envelope from server", nil) - - res, err := r.HTTPClient.Do(req) - if err != nil { - // 로컬 요청 실패 시, 502 + 에러 메시지를 스트림 응답으로 전송합니다. (ko) - // On local request failure, send a 502 response over the stream. (en) - errMsg := fmt.Sprintf("perform http request: %v", err) - streamRespHeader := map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - protocol.HeaderKeyStatus: {strconv.Itoa(http.StatusBadGateway)}, - } - respOpen := protocol.Envelope{ - Type: protocol.MessageTypeStreamOpen, - StreamOpen: &protocol.StreamOpen{ - ID: streamID, - Service: so.Service, - TargetAddr: so.TargetAddr, - Header: streamRespHeader, - }, - } - if err2 := codec.Encode(r.sess, &respOpen); err2 != nil { - logReq.Error("failed to encode stream response open envelope (error path)", logging.Fields{ - "error": err2.Error(), - }) - return err2 - } - - dataEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamData, - StreamData: &protocol.StreamData{ - ID: streamID, - Seq: 0, - Data: []byte("HopGate: " + errMsg), - }, - } - // 에러 응답 프레임도 ARQ 대상에 등록합니다. - sender.register(0, dataEnv.StreamData.Data) - if err2 := codec.Encode(r.sess, &dataEnv); err2 != nil { - logReq.Error("failed to encode stream response data envelope (error path)", logging.Fields{ - "error": err2.Error(), - }) - return err2 - } - - closeEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamClose, - StreamClose: &protocol.StreamClose{ - ID: streamID, - Error: errMsg, - }, - } - if err2 := codec.Encode(r.sess, &closeEnv); err2 != nil { - logReq.Error("failed to encode stream response close envelope (error path)", logging.Fields{ - "error": err2.Error(), - }) - return err2 - } - - logReq.Error("local http request failed (stream)", logging.Fields{ - "error": err.Error(), - }) - return nil - } - defer res.Body.Close() - - // 응답을 StreamOpen + StreamData(4KiB chunk) + StreamClose 프레임으로 전송합니다. (ko) - // Send the response as StreamOpen + StreamData (4KiB chunks) + StreamClose frames. (en) - - // 응답 헤더 맵을 복사하고 상태 코드를 pseudo-header 로 추가합니다. (ko) - // Copy response headers and attach status code as a pseudo-header. (en) - streamRespHeader := make(map[string][]string, len(res.Header)+1) - for k, vs := range res.Header { - streamRespHeader[k] = append([]string(nil), vs...) - } - statusCode := res.StatusCode - if statusCode == 0 { - statusCode = http.StatusOK - } - streamRespHeader[protocol.HeaderKeyStatus] = []string{strconv.Itoa(statusCode)} - - respOpen := protocol.Envelope{ - Type: protocol.MessageTypeStreamOpen, - StreamOpen: &protocol.StreamOpen{ - ID: streamID, - Service: so.Service, - TargetAddr: so.TargetAddr, - Header: streamRespHeader, - }, - } - - if err := codec.Encode(r.sess, &respOpen); err != nil { - logReq.Error("failed to encode stream response open envelope", logging.Fields{ - "error": err.Error(), - }) - return err - } - - // 응답 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko) - // Chunk the response body into 4KiB (StreamChunkSize) StreamData frames. (en) - var seq uint64 - chunk := make([]byte, protocol.StreamChunkSize) - for { - n, err := res.Body.Read(chunk) - if n > 0 { - dataCopy := append([]byte(nil), chunk[:n]...) - // 송신 측 ARQ: Seq 별 payload 를 기록해 두었다가, StreamAck 의 LostSeqs 를 기반으로 재전송할 수 있습니다. - sender.register(seq, dataCopy) - - dataEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamData, - StreamData: &protocol.StreamData{ - ID: streamID, - Seq: seq, - Data: dataCopy, - }, - } - if err2 := codec.Encode(r.sess, &dataEnv); err2 != nil { - logReq.Error("failed to encode stream response data envelope", logging.Fields{ - "error": err2.Error(), - }) - return err2 - } - seq++ - } - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read http response body for streaming: %w", err) - } - } - - closeEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamClose, - StreamClose: &protocol.StreamClose{ - ID: streamID, - Error: "", - }, - } - - if err := codec.Encode(r.sess, &closeEnv); err != nil { - logReq.Error("failed to encode stream response close envelope", logging.Fields{ - "error": err.Error(), - }) - return err - } - - logReq.Info("stream http response sent to server", logging.Fields{ - "status": statusCode, - "elapsed_ms": time.Since(start).Milliseconds(), - "error": "", - }) - - return nil -} - -func (p *ClientProxy) registerStreamSender(id protocol.StreamID, sender *streamSender) { - p.sendersMu.Lock() - defer p.sendersMu.Unlock() - if p.streamSenders == nil { - p.streamSenders = make(map[protocol.StreamID]*streamSender) - } - p.streamSenders[id] = sender -} - -func (p *ClientProxy) unregisterStreamSender(id protocol.StreamID) { - p.sendersMu.Lock() - defer p.sendersMu.Unlock() - if p.streamSenders == nil { - return - } - delete(p.streamSenders, id) -} - -func (p *ClientProxy) getStreamSender(id protocol.StreamID) *streamSender { - p.sendersMu.Lock() - defer p.sendersMu.Unlock() - if p.streamSenders == nil { - return nil - } - return p.streamSenders[id] -} - -// StartLoop 는 단일 DTLS 세션에 대한 **중앙 readLoop** 역할을 수행합니다. (ko) -// StartLoop acts as the **central read loop** for a single DTLS session. (en) -// -// 3.3B.1 Design note — client-side DTLS session multiplexing: -// -// - 목표: -// - DTLS 세션 레벨에서는 오직 `protocol.Envelope` 를 연속해서 읽고(decoding), -// 각 Envelope 를 **스트림 단위로 demux** 하는 역할만 맡습니다. -// - 실제 HTTP 처리(요청 바디 수신, 로컬 HTTP 호출, 응답 스트림 전송)는 -// 개별 스트림 전용 goroutine/구조체(`streamReceiver` 등)가 담당하도록 분리합니다. -// -// - 스트림 demux 자료구조(계획): -// - `recvTable: map[protocol.StreamID]*streamReceiver` 형태의 수신 테이블을 유지합니다. -// - 각 `streamReceiver` 는 자신만의 입력 채널을 가집니다. 예: `inCh chan *protocol.Envelope`. -// - 중앙 readLoop 는 DTLS 세션에서 Envelope 를 읽은 뒤, -// - `env.Type == MessageTypeStreamOpen` 인 경우: -// - `id := env.StreamOpen.ID` 로 stream ID 를 구하고, -// - `recvTable[id]` 가 없으면 새 `streamReceiver` 를 생성해 goroutine 을 띄운 뒤 -// 첫 메시지(`env`)를 `receiver.inCh <- env` 로 전달합니다. -// - `env.Type == MessageTypeStreamData` / `MessageTypeStreamClose` 인 경우: -// - `id := env.StreamData.ID` 또는 `env.StreamClose.ID` 로 stream ID 를 구하고, -// - 기존 `recvTable[id]` 를 찾아 `receiver.inCh <- env` 로 전달합니다. -// - receiver 가 존재하지 않으면 해당 스트림에 한정된 프로토콜 에러로 처리할지 정책을 정의합니다. -// - `env.Type == MessageTypeStreamAck` 인 경우: -// - 이미 구현된 송신 측 ARQ 테이블(`streamSenders`)을 조회해 재전송 로직에 전달합니다. -// -// - 현재 구현 상태와 향후 리팩터링 경계: -// - 지금은 `MessageTypeStreamOpen` 을 수신하면 곧바로 `handleStreamRequest` 를 호출하고, -// 이 함수가 `reader` 를 직접 소비하면서 같은 세션 안에 **동시에 하나의 스트림만** 처리할 수 있습니다. -// - 3.3B.2 / 3.3B.3 단계에서는 위에서 설명한 demux 설계에 맞춰 -// - `handleStreamRequest` 내부 HTTP 매핑 로직을 `streamReceiver` 로 옮기고, -// - StartLoop 가 DTLS 세션 → per-stream goroutine 으로 이벤트를 분배하는 역할만 수행하도록 -// 점진적으로 리팩터링할 예정입니다. -func (p *ClientProxy) StartLoop(ctx context.Context, sess io.ReadWriter) error { - if ctx == nil { - ctx = context.Background() - } - log := p.Logger - - // NOTE: pion/dtls 는 복호화된 애플리케이션 데이터를 호출자가 제공한 버퍼에 채워 넣습니다. - // DTLS는 UDP 기반이므로 한 번의 Read()에서 전체 datagram을 읽어야 하며, - // pion/dtls 내부 버퍼 한계(8KB)를 초과하는 메시지는 "dtls: buffer too small" 오류를 발생시킵니다. - // 이를 방지하기 위해 DTLS 세션을 bufio.Reader로 감싸서 datagram을 완전히 읽어들인 후 파싱합니다. (ko) - // NOTE: pion/dtls decrypts application data into the buffer provided by the caller. - // Since DTLS is UDP-based, the entire datagram must be read in a single Read() call, - // and messages exceeding pion/dtls's internal buffer limit (8KB) will trigger - // "dtls: buffer too small" errors. To prevent this, we wrap the DTLS session with - // a bufio.Reader to fully read the datagram before parsing. (en) - codec := protocol.DefaultCodec - bufferedReader := bufio.NewReaderSize(sess, protocol.GetDTLSReadBufferSize()) - - // 스트림 수신기 테이블: 중앙 readLoop 가 StreamOpen/Data/Close 를 - // 각 streamReceiver 로 demux 하기 위해 사용합니다. (ko) - // Per-session stream receiver table used by the central read loop to - // demultiplex StreamOpen/Data/Close frames. (en) - receivers := make(map[protocol.StreamID]*streamReceiver) - var receiversMu sync.Mutex - - getReceiver := func(id protocol.StreamID) *streamReceiver { - receiversMu.Lock() - defer receiversMu.Unlock() - return receivers[id] - } - - addReceiver := func(id protocol.StreamID, rcv *streamReceiver) { - receiversMu.Lock() - receivers[id] = rcv - receiversMu.Unlock() - } - - removeReceiver := func(id protocol.StreamID) { - receiversMu.Lock() - delete(receivers, id) - receiversMu.Unlock() - } - - closeAllReceivers := func() { - receiversMu.Lock() - defer receiversMu.Unlock() - for id, rcv := range receivers { - close(rcv.inCh) - delete(receivers, id) - } - } - - for { - select { - case <-ctx.Done(): - log.Info("client proxy loop stopping due to context cancellation", logging.Fields{ - "reason": ctx.Err().Error(), - }) - closeAllReceivers() - return nil - default: - } - - var env protocol.Envelope - if err := codec.Decode(bufferedReader, &env); err != nil { - if err == io.EOF { - log.Info("dtls session closed by server", nil) - closeAllReceivers() - return nil - } - log.Error("failed to decode protocol envelope", logging.Fields{ - "error": err.Error(), - }) - closeAllReceivers() - return err - } - - switch env.Type { - case protocol.MessageTypeHTTP: - if err := p.handleHTTPEnvelope(ctx, sess, &env); err != nil { - log.Error("failed to handle http envelope", logging.Fields{ - "error": err.Error(), - }) - closeAllReceivers() - return err - } - - case protocol.MessageTypeStreamAck: - // 송신 측 ARQ: 서버 → 클라이언트 응답 스트림에 대한 StreamAck 처리. (ko) - // Sender-side ARQ: handle StreamAck for response streams (server → client). (en) - sa := env.StreamAck - if sa == nil { - log.Error("received stream_ack envelope with nil payload", nil) - closeAllReceivers() - return fmt.Errorf("stream_ack payload is nil") - } - streamID := protocol.StreamID(sa.ID) - sender := p.getStreamSender(streamID) - if sender == nil { - log.Warn("received stream_ack for unknown stream", logging.Fields{ - "stream_id": sa.ID, - }) - continue - } - lost := sender.handleAck(sa) - // LostSeqs 를 기반으로 선택적 재전송 수행 (Selective Retransmission). (ko) - // Perform selective retransmission based on LostSeqs. (en) - for seq, data := range lost { - retryEnv := protocol.Envelope{ - Type: protocol.MessageTypeStreamData, - StreamData: &protocol.StreamData{ - ID: streamID, - Seq: seq, - Data: data, - }, - } - if err := codec.Encode(sess, &retryEnv); err != nil { - log.Error("failed to retransmit stream_data after stream_ack", logging.Fields{ - "stream_id": streamID, - "seq": seq, - "error": err.Error(), - }) - closeAllReceivers() - return err - } - log.Info("retransmitted stream_data after stream_ack", logging.Fields{ - "stream_id": streamID, - "seq": seq, - }) - } - - case protocol.MessageTypeStreamOpen: - // 새로운 스트림에 대한 수신기 생성 및 goroutine 실행. (ko) - // Create a new streamReceiver and start its goroutine for this stream. (en) - so := env.StreamOpen - if so == nil { - log.Error("stream_open envelope missing payload", nil) - continue - } - streamID := so.ID - if streamID == "" { - log.Error("stream_open with empty stream id", nil) - continue - } - if p.LocalTarget == "" { - closeAllReceivers() - return fmt.Errorf("local target is empty") - } - - if existing := getReceiver(streamID); existing != nil { - log.Error("duplicate stream_open for existing stream", logging.Fields{ - "stream_id": streamID, - }) - continue - } - - sender := newStreamSender() - p.registerStreamSender(streamID, sender) - - receiver := newStreamReceiver(streamID, sess, codec, log, p.HTTPClient, p.LocalTarget) - addReceiver(streamID, receiver) - - go func(id protocol.StreamID, r *streamReceiver, so *protocol.StreamOpen, snd *streamSender) { - if err := r.run(ctx, so, snd); err != nil { - log.Error("stream receiver terminated with error", logging.Fields{ - "stream_id": id, - "error": err.Error(), - }) - } - removeReceiver(id) - p.unregisterStreamSender(id) - }(streamID, receiver, so, sender) - - case protocol.MessageTypeStreamData: - // StreamData 는 중앙 readLoop 에서 해당 streamReceiver 로 demux 됩니다. (ko) - // StreamData frames are demultiplexed to the corresponding streamReceiver. (en) - sd := env.StreamData - if sd == nil { - log.Error("stream_data envelope with nil payload", nil) - continue - } - streamID := sd.ID - receiver := getReceiver(streamID) - if receiver == nil { - log.Warn("received stream_data for unknown stream", logging.Fields{ - "stream_id": streamID, - }) - continue - } - envCopy := env - select { - case receiver.inCh <- &envCopy: - case <-ctx.Done(): - closeAllReceivers() - return nil - } - - case protocol.MessageTypeStreamClose: - // StreamClose 역시 중앙 readLoop 에서 해당 streamReceiver 로 전달합니다. (ko) - // StreamClose is also forwarded from the central readLoop to streamReceiver. (en) - sc := env.StreamClose - if sc == nil { - log.Error("stream_close envelope with nil payload", nil) - continue - } - streamID := sc.ID - receiver := getReceiver(streamID) - if receiver == nil { - log.Warn("received stream_close for unknown stream", logging.Fields{ - "stream_id": streamID, - }) - continue - } - envCopy := env - select { - case receiver.inCh <- &envCopy: - // 수명주기 정리는 receiver.run 내부와 goroutine 종료 시 removeReceiver 에서 수행됩니다. (ko) - // Lifecycle cleanup is handled inside receiver.run and the goroutine's defer. (en) - case <-ctx.Done(): - closeAllReceivers() - return nil - } - - default: - log.Error("received unsupported envelope type from server", logging.Fields{ - "type": env.Type, - }) - closeAllReceivers() - return fmt.Errorf("unsupported envelope type %q", env.Type) - } - } -} - -// handleHTTPEnvelope 는 기존 단일 HTTP 요청/응답 Envelope 경로를 처리합니다. (ko) -// handleHTTPEnvelope handles the legacy single HTTP request/response envelope path. (en) -func (p *ClientProxy) handleHTTPEnvelope(ctx context.Context, sess io.ReadWriter, env *protocol.Envelope) error { - if env.HTTPRequest == nil { - return fmt.Errorf("http envelope missing http_request payload") - } - - req := env.HTTPRequest - log := p.Logger - start := time.Now() - - logReq := log.With(logging.Fields{ - "request_id": req.RequestID, - "service": req.ServiceName, - "method": req.Method, - "url": req.URL, - "client_id": req.ClientID, - "local_target": p.LocalTarget, - }) - logReq.Info("received http envelope from server", nil) - - resp := protocol.Response{ - RequestID: req.RequestID, - Header: make(map[string][]string), - } - - // 로컬 HTTP 요청 수행 - if err := p.forwardToLocal(ctx, req, &resp); err != nil { - resp.Status = http.StatusBadGateway - resp.Error = err.Error() - logReq.Error("local http request failed", logging.Fields{ - "error": err.Error(), - }) - } - - // HTTP 응답을 Envelope 로 감싸서 서버로 전송합니다. - respEnv := protocol.Envelope{ - Type: protocol.MessageTypeHTTP, - HTTPResponse: &resp, - } - - if err := protocol.DefaultCodec.Encode(sess, &respEnv); err != nil { - logReq.Error("failed to encode http response envelope", logging.Fields{ - "error": err.Error(), - }) - return err - } - - logReq.Info("http response envelope sent to server", logging.Fields{ - "status": resp.Status, - "elapsed_ms": time.Since(start).Milliseconds(), - "error": resp.Error, - }) - - return nil -} - -// handleStreamRequest 는 StreamOpen/StreamData/StreamClose 기반 HTTP 요청/응답 스트림을 처리합니다. (ko) -// handleStreamRequest handles an HTTP request/response exchange using StreamOpen/StreamData/StreamClose frames. (en) -func (p *ClientProxy) handleStreamRequest(ctx context.Context, sess io.ReadWriter, reader io.Reader, openEnv *protocol.Envelope) error { - codec := protocol.DefaultCodec - log := p.Logger - - so := openEnv.StreamOpen - if so == nil { - return fmt.Errorf("stream_open envelope missing payload") - } - - streamID := so.ID - // 이 스트림에 대한 송신 측 ARQ 상태를 준비하고, StartLoop 에서 들어오는 StreamAck 와 연동합니다. - sender := newStreamSender() - p.registerStreamSender(streamID, sender) - defer p.unregisterStreamSender(streamID) - - if p.LocalTarget == "" { - return fmt.Errorf("local target is empty") - } - - // streamReceiver 를 생성해 스트림 수신/HTTP 매핑/응답 전송을 전담시킵니다. (ko) - // Delegate per-stream RX/HTTP mapping/response to a streamReceiver. (en) - receiver := newStreamReceiver(streamID, sess, codec, log, p.HTTPClient, p.LocalTarget) - - // streamReceiver 수명주기를 별도 goroutine 으로 실행합니다. (ko) - // Run the streamReceiver lifecycle in a separate goroutine. (en) - errCh := make(chan error, 1) - go func() { - errCh <- receiver.run(ctx, so, sender) - }() - - for { - var env protocol.Envelope - if err := codec.Decode(reader, &env); err != nil { - if err == io.EOF { - // DTLS 세션이 조기 종료되면 receiver 에게 더 이상 프레임이 없음을 알리고 종료를 기다립니다. (ko) - // On EOF, close the channel so receiver can terminate gracefully. (en) - close(receiver.inCh) - if recvErr := <-errCh; recvErr != nil { - return recvErr - } - return fmt.Errorf("unexpected EOF while reading stream request body") - } - close(receiver.inCh) - if recvErr := <-errCh; recvErr != nil { - return recvErr - } - return fmt.Errorf("decode stream request frame: %w", err) - } - - switch env.Type { - case protocol.MessageTypeStreamData: - sd := env.StreamData - if sd == nil { - close(receiver.inCh) - _ = <-errCh - return fmt.Errorf("stream_data payload is nil") - } - if sd.ID != streamID { - close(receiver.inCh) - _ = <-errCh - return fmt.Errorf("stream_data for unexpected stream id %q (expected %q)", sd.ID, streamID) - } - envCopy := env - receiver.inCh <- &envCopy - - case protocol.MessageTypeStreamClose: - sc := env.StreamClose - if sc == nil { - close(receiver.inCh) - _ = <-errCh - return fmt.Errorf("stream_close payload is nil") - } - if sc.ID != streamID { - close(receiver.inCh) - _ = <-errCh - return fmt.Errorf("stream_close for unexpected stream id %q (expected %q)", sc.ID, streamID) - } - // StreamClose 프레임을 receiver 에게 전달한 뒤 채널을 닫고 종료를 기다립니다. (ko) - // After forwarding StreamClose, close the channel and wait for receiver to finish. (en) - envCopy := env - receiver.inCh <- &envCopy - close(receiver.inCh) - return <-errCh - - default: - // 예상치 못한 Envelope 타입: 해당 스트림에 한정된 프로토콜 에러로 보고 receiver 를 종료합니다. (ko) - // Unexpected envelope type: treat as per-stream protocol error and shut down receiver. (en) - close(receiver.inCh) - if recvErr := <-errCh; recvErr != nil { - return recvErr - } - return fmt.Errorf("unexpected envelope type %q while reading stream request body", env.Type) - } - } -} - -// forwardToLocal 는 protocol.Request 를 로컬 HTTP 요청으로 변환하고 protocol.Response 를 채웁니다. (ko) -// forwardToLocal converts a protocol.Request into a local HTTP request and fills protocol.Response. (en) -func (p *ClientProxy) forwardToLocal(ctx context.Context, preq *protocol.Request, presp *protocol.Response) error { - if p.LocalTarget == "" { - return fmt.Errorf("local target is empty") - } - - // 요청 URL을 local target 기준으로 재구성 - u, err := url.Parse(preq.URL) - if err != nil { - return fmt.Errorf("parse url: %w", err) - } - u.Scheme = "http" - u.Host = p.LocalTarget - - req, err := http.NewRequestWithContext(ctx, preq.Method, u.String(), nil) - if err != nil { - return fmt.Errorf("create http request: %w", err) - } - // Body 설정 (원본 바이트를 그대로 사용) - if len(preq.Body) > 0 { - buf := bytes.NewReader(preq.Body) - req.Body = io.NopCloser(buf) - req.ContentLength = int64(len(preq.Body)) - } - // 헤더 복사 - for k, vs := range preq.Header { - for _, v := range vs { - req.Header.Add(k, v) - } - } - - res, err := p.HTTPClient.Do(req) - if err != nil { - return fmt.Errorf("perform http request: %w", err) - } - defer res.Body.Close() - - presp.Status = res.StatusCode - for k, vs := range res.Header { - presp.Header[k] = append([]string(nil), vs...) - } - - // DTLS over UDP has an upper bound on packet size (~64KiB). 전체 HTTP 바디를 - // 하나의 Envelope 로 감싸 전송하는 현재 설계에서는, 바디가 너무 크면 - // OS 레벨에서 "message too long" (EMSGSIZE) 가 발생할 수 있습니다. (ko) - // - // 이를 피하기 위해, 터널링 가능한 **단일 HTTP 바디** 크기에 상한을 두고, - // 이를 초과하는 응답은 502 Bad Gateway + HopGate 전용 에러 메시지로 대체합니다. (ko) - // - // DTLS over UDP has an upper bound on datagram size (~64KiB). With the current - // single-envelope design, very large bodies can still trigger "message too long" - // (EMSGSIZE) at the OS level. To avoid this, we cap the tunneled HTTP body size - // and replace oversized responses with a 502 Bad Gateway + HopGate-specific - // error body. (en) - // - // Protobuf 기반 터널링에서는 향후 StreamData(4KiB) 단위로 나누어 전송할 예정이지만, - // 그 전 단계에서도 body 자체를 4KiB( StreamChunkSize )로 하드 리밋하여 - // Proto message body 필드가 지나치게 커지지 않도록 합니다. (ko) - // - // Even before full stream tunneling is implemented, we hard-limit the protobuf - // body field to 4KiB (StreamChunkSize) so that individual messages remain small. (en) - const maxTunnelBodyBytes = protocol.StreamChunkSize - - limited := &io.LimitedReader{ - R: res.Body, - N: maxTunnelBodyBytes + 1, // read up to limit+1 to detect overflow - } - body, err := io.ReadAll(limited) - if err != nil { - return fmt.Errorf("read http response body: %w", err) - } - if len(body) > maxTunnelBodyBytes { - // 응답 바디가 너무 커서 DTLS/UDP 로 안전하게 전송하기 어렵기 때문에, - // 원본 바디 대신 HopGate 에러 응답으로 대체합니다. (ko) - // - // The response body is too large to be safely tunneled over DTLS/UDP. - // Replace it with a HopGate error response instead of attempting to - // send an oversized datagram. (en) - presp.Status = http.StatusBadGateway - presp.Header = map[string][]string{ - "Content-Type": {"text/plain; charset=utf-8"}, - } - presp.Body = []byte("HopGate: response body too large for DTLS tunnel (over max_tunnel_body_bytes)") - presp.Error = "response body too large for DTLS tunnel" - return nil - } - - presp.Body = body - - return nil -} - -// firstHeaderValue 는 주어진 키의 첫 번째 헤더 값을 반환하고, 없으면 기본값을 반환합니다. (ko) -// firstHeaderValue returns the first header value for a key, or a default if absent. (en) -func firstHeaderValue(hdr map[string][]string, key, def string) string { - if hdr == nil { - return def - } - if vs, ok := hdr[key]; ok && len(vs) > 0 { - return vs[0] - } - return def -} diff --git a/internal/proxy/server.go b/internal/proxy/server.go deleted file mode 100644 index 348d5c9..0000000 --- a/internal/proxy/server.go +++ /dev/null @@ -1,43 +0,0 @@ -package proxy - -import ( - "context" - "net/http" - - "golang.org/x/net/http2" -) - -// ServerProxy 는 공인 HTTP(S) 엔드포인트에서 들어오는 요청을 -// 적절한 클라이언트로 라우팅하는 서버 측 프록시입니다. -type ServerProxy struct { - Router Router - HTTPServer *http.Server -} - -// Router 는 도메인/패스 기준으로 어떤 클라이언트/서비스로 보낼지 결정하는 인터페이스입니다. -type Router interface { - Route(req *http.Request) (clientID string, serviceName string, err error) -} - -// NewHTTPServer 는 H1/H2 를 지원하는 기본 HTTP 서버를 생성합니다. -func NewHTTPServer(addr string, handler http.Handler) *http.Server { - srv := &http.Server{ - Addr: addr, - Handler: handler, - } - http2.ConfigureServer(srv, &http2.Server{}) - return srv -} - -// Start / Shutdown 등은 추후 구현합니다. -func (p *ServerProxy) Start(ctx context.Context) error { - // TODO: HTTP/HTTPS 리스너 시작 및 DTLS 연동 - return nil -} - -func (p *ServerProxy) Shutdown(ctx context.Context) error { - if p.HTTPServer != nil { - return p.HTTPServer.Shutdown(ctx) - } - return nil -} diff --git a/internal/tunnel/types.go b/internal/tunnel/types.go new file mode 100644 index 0000000..afc8a6f --- /dev/null +++ b/internal/tunnel/types.go @@ -0,0 +1,8 @@ +package tunnel + +import "context" + +// DomainValidator validates the client credentials presented on the control stream. +type DomainValidator interface { + ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error +} diff --git a/internal/tunnel/yamux.go b/internal/tunnel/yamux.go new file mode 100644 index 0000000..d701d93 --- /dev/null +++ b/internal/tunnel/yamux.go @@ -0,0 +1,222 @@ +// Package tunnel provides the transport-neutral session used by HopGate. +// The wire transport is TLS over TCP, while yamux supplies bidirectional +// logical streams on top of the connection. +package tunnel + +import ( + "context" + "crypto/tls" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/hashicorp/yamux" +) + +const ( + metadataHeaderSize = 4 + maxMetadataSize = 64 << 10 +) + +// StreamMeta describes what a logical stream carries. HTTP and WebSocket +// streams use the same transport; only the metadata kind differs. +type StreamMeta struct { + Kind string `json:"kind"` + Domain string `json:"domain,omitempty"` + Service string `json:"service,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` + Headers map[string][]string `json:"headers,omitempty"` + Target string `json:"target,omitempty"` +} + +// Stream is a yamux stream with one metadata record at its beginning. +type Stream struct { + net.Conn + Meta StreamMeta +} + +// Session is a bidirectional multiplexed tunnel. +type Session struct { + inner *yamux.Session +} + +func newSession(conn net.Conn, server bool) (*Session, error) { + if conn == nil { + return nil, errors.New("tunnel: nil connection") + } + config := yamux.DefaultConfig() + config.EnableKeepAlive = true + config.KeepAliveInterval = 30 * time.Second + config.ConnectionWriteTimeout = 10 * time.Second + + var session *yamux.Session + var err error + if server { + session, err = yamux.Server(conn, config) + } else { + session, err = yamux.Client(conn, config) + } + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("tunnel: create yamux session: %w", err) + } + return &Session{inner: session}, nil +} + +// NewClient wraps an already-established TLS or test connection. +func NewClient(conn net.Conn) (*Session, error) { return newSession(conn, false) } + +// NewServer wraps an accepted TLS or test connection. +func NewServer(conn net.Conn) (*Session, error) { return newSession(conn, true) } + +// DialTLS dials the server and establishes a TLS-protected yamux session. +func DialTLS(ctx context.Context, address string, config *tls.Config) (*Session, error) { + if config == nil { + return nil, errors.New("tunnel: nil TLS config") + } + dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: 10 * time.Second}, Config: config} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("tunnel: TLS dial: %w", err) + } + return NewClient(conn) +} + +// Open creates a stream and writes its metadata before returning it. +func (s *Session) Open(ctx context.Context, meta StreamMeta) (*Stream, error) { + if s == nil || s.inner == nil { + return nil, errors.New("tunnel: session is closed") + } + if err := checkContext(ctx); err != nil { + return nil, err + } + conn, err := s.inner.Open() + if err != nil { + return nil, fmt.Errorf("tunnel: open stream: %w", err) + } + clearDeadline := true + defer func() { + if clearDeadline { + _ = conn.SetDeadline(time.Time{}) + } + }() + if deadline, ok := ctxDeadline(ctx); ok { + if err := conn.SetDeadline(deadline); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("tunnel: set stream deadline: %w", err) + } + } + if err := writeMeta(conn, meta); err != nil { + _ = conn.Close() + return nil, err + } + clearDeadline = false + _ = conn.SetDeadline(time.Time{}) + return &Stream{Conn: conn, Meta: meta}, nil +} + +// Accept waits for a peer-created stream and reads its metadata. +func (s *Session) Accept(ctx context.Context) (*Stream, error) { + if s == nil || s.inner == nil { + return nil, errors.New("tunnel: session is closed") + } + if err := checkContext(ctx); err != nil { + return nil, err + } + conn, err := s.inner.Accept() + if err != nil { + return nil, fmt.Errorf("tunnel: accept stream: %w", err) + } + if deadline, ok := ctxDeadline(ctx); ok { + if err := conn.SetDeadline(deadline); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("tunnel: set stream deadline: %w", err) + } + } + meta, err := readMeta(conn) + if err != nil { + _ = conn.Close() + return nil, err + } + _ = conn.SetDeadline(time.Time{}) + return &Stream{Conn: conn, Meta: meta}, nil +} + +func (s *Session) Close() error { + if s == nil || s.inner == nil { + return nil + } + return s.inner.Close() +} + +// IsClosed reports whether the underlying yamux session has terminated. +func (s *Session) IsClosed() bool { + return s == nil || s.inner == nil || s.inner.IsClosed() +} + +func checkContext(ctx context.Context) error { + if ctx == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + return nil +} + +func ctxDeadline(ctx context.Context) (time.Time, bool) { + if ctx == nil { + return time.Time{}, false + } + return ctx.Deadline() +} + +func writeMeta(w io.Writer, meta StreamMeta) error { + data, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("tunnel: encode stream metadata: %w", err) + } + if len(data) > maxMetadataSize { + return fmt.Errorf("tunnel: stream metadata exceeds %d bytes", maxMetadataSize) + } + header := make([]byte, metadataHeaderSize) + binary.BigEndian.PutUint32(header, uint32(len(data))) + if _, err := w.Write(header); err != nil { + return fmt.Errorf("tunnel: write metadata length: %w", err) + } + if _, err := w.Write(data); err != nil { + return fmt.Errorf("tunnel: write metadata: %w", err) + } + return nil +} + +func readMeta(r io.Reader) (StreamMeta, error) { + header := make([]byte, metadataHeaderSize) + if _, err := io.ReadFull(r, header); err != nil { + return StreamMeta{}, fmt.Errorf("tunnel: read metadata length: %w", err) + } + size := binary.BigEndian.Uint32(header) + if size == 0 || size > maxMetadataSize { + return StreamMeta{}, fmt.Errorf("tunnel: invalid metadata size %d", size) + } + data := make([]byte, size) + if _, err := io.ReadFull(r, data); err != nil { + return StreamMeta{}, fmt.Errorf("tunnel: read metadata: %w", err) + } + var meta StreamMeta + if err := json.Unmarshal(data, &meta); err != nil { + return StreamMeta{}, fmt.Errorf("tunnel: decode stream metadata: %w", err) + } + if meta.Kind == "" { + return StreamMeta{}, errors.New("tunnel: stream metadata kind is required") + } + return meta, nil +} diff --git a/internal/tunnel/yamux_test.go b/internal/tunnel/yamux_test.go new file mode 100644 index 0000000..dd4858d --- /dev/null +++ b/internal/tunnel/yamux_test.go @@ -0,0 +1,84 @@ +package tunnel + +import ( + "context" + "net" + "testing" +) + +func TestSessionOpenAcceptAndBidirectionalData(t *testing.T) { + left, right := net.Pipe() + defer left.Close() + defer right.Close() + + serverCh := make(chan *Session, 1) + serverErrCh := make(chan error, 1) + go func() { + session, err := NewServer(right) + if err != nil { + serverErrCh <- err + return + } + serverCh <- session + }() + + client, err := NewClient(left) + if err != nil { + t.Fatalf("create client session: %v", err) + } + defer client.Close() + + var server *Session + select { + case err := <-serverErrCh: + t.Fatalf("create server session: %v", err) + case server = <-serverCh: + } + defer server.Close() + + meta := StreamMeta{ + Kind: "http", + Method: "POST", + Path: "/upload", + Headers: map[string][]string{"Content-Type": {"application/octet-stream"}}, + } + clientStream, err := client.Open(context.Background(), meta) + if err != nil { + t.Fatalf("open stream: %v", err) + } + defer clientStream.Close() + + serverStream, err := server.Accept(context.Background()) + if err != nil { + t.Fatalf("accept stream: %v", err) + } + defer serverStream.Close() + + if serverStream.Meta.Kind != meta.Kind || serverStream.Meta.Path != meta.Path { + t.Fatalf("metadata mismatch: got %#v, want %#v", serverStream.Meta, meta) + } + + const request = "request-body" + if _, err := clientStream.Write([]byte(request)); err != nil { + t.Fatalf("write request: %v", err) + } + buf := make([]byte, len(request)) + if _, err := serverStream.Read(buf); err != nil { + t.Fatalf("read request: %v", err) + } + if string(buf) != request { + t.Fatalf("request mismatch: got %q, want %q", buf, request) + } + + const response = "response-body" + if _, err := serverStream.Write([]byte(response)); err != nil { + t.Fatalf("write response: %v", err) + } + buf = make([]byte, len(response)) + if _, err := clientStream.Read(buf); err != nil { + t.Fatalf("read response: %v", err) + } + if string(buf) != response { + t.Fatalf("response mismatch: got %q, want %q", buf, response) + } +} diff --git a/package-lock.json b/package-lock.json index 9e75da2..1fb82a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@tailwindcss/cli": "^4.1.17", - "tailwindcss": "^4.1.17" + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" } }, "node_modules/@jridgewell/gen-mapping": { @@ -46,9 +46,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -373,68 +373,68 @@ } }, "node_modules/@tailwindcss/cli": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.17.tgz", - "integrity": "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", "dev": true, "license": "MIT", "dependencies": { - "@parcel/watcher": "^2.5.1", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "enhanced-resolve": "^5.18.3", + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", "mri": "^1.2.0", "picocolors": "^1.1.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.3.3" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -445,13 +445,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -462,13 +462,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -479,13 +479,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -496,13 +496,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -513,81 +513,93 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -603,21 +615,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -628,13 +640,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -645,7 +657,7 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/braces": { @@ -675,14 +687,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -742,9 +754,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -752,9 +764,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -768,23 +780,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -803,9 +815,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -824,9 +836,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -845,9 +857,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -866,9 +878,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -887,13 +899,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -908,13 +923,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -929,13 +947,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -950,13 +971,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -971,9 +995,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -992,9 +1016,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1094,16 +1118,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d431b48..632d2fc 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build:errors-css": "tailwindcss -c ./tools/tailwind/tailwind.config.cjs -i ./tools/tailwind/input.css -o ./internal/errorpages/assets/errors.css --minify" }, "devDependencies": { - "@tailwindcss/cli": "^4.1.17", - "tailwindcss": "^4.1.17" + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" } } diff --git a/progress.md b/progress.md deleted file mode 100644 index 0826bd1..0000000 --- a/progress.md +++ /dev/null @@ -1,353 +0,0 @@ -# HopGate Progress / 진행 현황 - -이 문서는 HopGate 아키텍처 대비 현재 구현 상태와 이후 추가해야 할 작업을 정리한 Milestone 문서입니다. (ko) -This document tracks implementation progress against the HopGate architecture and lists remaining milestones. (en) - ---- - -## 1. High-level Status / 상위 수준 상태 - -- 아키텍처 문서 및 README 정리 완료 (ko/en 병기). - Architecture and README are documented in both Korean and English. -- 서버/클라이언트 엔트리 포인트, DTLS 핸드셰이크, 기본 PostgreSQL/ent 스키마까지 1차 뼈대 구현 완료. - First skeleton implementation is done for server/client entrypoints, DTLS handshake, and basic PostgreSQL/ent schema. -- 기본 Proxy 동작(HTTP ↔ DTLS 터널링), Admin API 비즈니스 로직, ACME 기반 인증서 관리는 구현 완료된 상태. - Core proxying (HTTP ↔ DTLS tunneling), admin API business logic, and ACME-based certificate management are implemented. -- 스트림 ARQ, Observability, Hardening, ACME 고급 전략 등은 아직 남아 있는 다음 단계 작업이다. - Stream-level ARQ, observability, hardening, and advanced ACME operational strategies remain as next-step work items. - ---- - -## 2. Completed Work / 완료된 작업 - -### 2.1 Documentation / 문서 - -- 아키텍처 개요: [`ARCHITECTURE.md`](ARCHITECTURE.md) - - ko/en 병기, 전체 구조/디렉터리/흐름/다음 단계 정리. (ko) - - Bilingual, documents overall structure, directories, flows, and next steps. (en) - -- 프로젝트 개요: [`README.md`](README.md) - - 사용법, DTLS 핸드셰이크 테스트 방법, Admin Plane 요약, 주의사항. (ko/en) - - Usage, DTLS handshake test guide, admin plane summary, caveats. (en) - -- 커밋 규칙: [`COMMIT_MESSAGE.md`](COMMIT_MESSAGE.md) - - `[type] short description [BREAK]` 형식, 타입 우선순위 정의, BREAK 규칙. (ko/en) - - Defines commit message format, type priorities, and `[BREAK]` convention. (en) - -- 아키텍처 그림용 프롬프트: [`architecture.prompt`](images/architecture.prompt) - - 외부 도구(예: 나노바나나 Pro)가 참조할 상세 다이어그램 지침. (en 설명 위주) - ---- - -### 2.2 Server / Client Entrypoints - -- 서버 메인: [`cmd/server/main.go`](cmd/server/main.go) - - 서버 설정 로드 (`LoadServerConfigFromEnv`). - - PostgreSQL 연결 및 ent 스키마 init (`store.OpenPostgresFromEnv`). - - Debug 모드 시 self-signed localhost cert 생성 (`dtls.NewSelfSignedLocalhostConfig`). - - DTLS 서버 생성 (`dtls.NewPionServer`) 및 Accept + Handshake 루프 (`PerformServerHandshake`). - - ent 기반 `DomainValidator` + `domainGateValidator` 를 사용해 `(domain, client_api_key)` 조합과 DNS/IP(옵션) 검증을 수행. - -- 클라이언트 메인: [`cmd/client/main.go`](cmd/client/main.go) - - CLI + env 병합 설정 (우선순위: CLI > env). - - `server_addr`, `domain`, `api_key`, `local_target`, `debug`. - - DTLS 클라이언트 생성 (`dtls.NewPionClient`) - - `Debug=true` 시 `InsecureSkipVerify=true` TLS 설정 사용. - - DTLS 핸드셰이크 수행 (`dtls.PerformClientHandshake`) - - 성공 시 도메인/로컬 타깃 로그 출력. - ---- - -### 2.3 Config / Env Handling - -- 공통 설정: [`internal/config/config.go`](internal/config/config.go) - - `ServerConfig` - - `HTTPListen`, `HTTPSListen`, `DTLSListen`, `Domain`, `ProxyDomains`, `Debug`, `Logging`. - - env: `HOP_SERVER_HTTP_LISTEN`, `HOP_SERVER_HTTPS_LISTEN`, `HOP_SERVER_DTLS_LISTEN`, `HOP_SERVER_DOMAIN`, `HOP_SERVER_PROXY_DOMAINS`, `HOP_SERVER_DEBUG`. - - `ClientConfig` - - `ServerAddr`, `Domain`, `ClientAPIKey`, `LocalTarget`, `Debug`, `Logging`. - - env: `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, `HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, `HOP_CLIENT_DEBUG`. - - `.env` 로더 (`loadDotEnvOnce`) + 각종 helper (`getEnvBool`, CSV 파싱 등). - -- DB 설정: [`internal/store/postgres.go`](internal/store/postgres.go) - - `ConfigFromEnv()` 로 DB 설정 로딩: - - `HOP_DB_DSN`, `HOP_DB_MAX_OPEN_CONNS`, `HOP_DB_MAX_IDLE_CONNS`, `HOP_DB_CONN_MAX_LIFETIME`. - -- `.env` 샘플: [`.env.example`](.env.example) - - Logging/Loki, 서버 포트, 클라이언트 설정, DB 설정 예시 포함. - ---- - -### 2.4 DTLS Layer / Handshake - -- 인터페이스: [`internal/dtls/dtls.go`](internal/dtls/dtls.go) - - `Session`, `Server`, `Client`. - -- pion/dtls 전송 구현: [`internal/dtls/transport_pion.go`](internal/dtls/transport_pion.go) - - `NewPionServer(PionServerConfig)` - - UDP 리스너 + DTLS 서버 (`piondtls.Listen`). - - `NewPionClient(PionClientConfig)` - - Timeout/TLSConfig 설정, `piondtls.Dial` 사용. - -- 핸드셰이크 로직: [`internal/dtls/handshake.go`](internal/dtls/handshake.go) - - 메시지: `handshakeRequest{domain, client_api_key}`, `handshakeResponse{ok, message, domain}`. - - `DomainValidator` 인터페이스. - - `PerformServerHandshake` / `PerformClientHandshake` 구현 완료. - -- self-signed TLS: [`internal/dtls/selfsigned.go`](internal/dtls/selfsigned.go) - - localhost CN, SAN(DNS/IP) 포함 self-signed cert 생성. - -- Domain Validator: - - 인터페이스 정의: [`internal/dtls/handshake.go`](internal/dtls/handshake.go) - - `ValidateDomainAPIKey(ctx, domain, clientAPIKey string) error`. - - 실제 구현: [`internal/admin/domain_validator.go`](internal/admin/domain_validator.go) - - ent.Client + PostgreSQL 기반으로 `Domain` 테이블 조회. - - 도메인 문자열은 `"host"` 또는 `"host:port"` 모두 허용하되, DB 조회 시에는 host 부분만 사용. - - `(domain, client_api_key)` 조합이 정확히 일치하는지 검증. - - DTLS 핸드셰이크 DNS/IP 게이트: [`cmd/server/main.go`](cmd/server/main.go:37) - - `canonicalizeDomainForDNS` + `domainGateValidator` 를 사용해, 클라이언트가 제시한 도메인의 A/AAAA 레코드가 `HOP_ACME_EXPECT_IPS` 에 설정된 IPv4/IPv6 IP 중 하나 이상과 일치하는지 검사한 뒤 DB 기반 `DomainValidator` 에 위임. - - `HOP_ACME_EXPECT_IPS` 가 비어 있는 경우에는 DNS/IP 검증을 생략하고 DB 검증만 수행. - - 기존 Dummy 구현: [`internal/dtls/validator_dummy.go`](internal/dtls/validator_dummy.go) 는 이제 개발/테스트용 참고 구현으로만 유지. - ---- - -### 2.5 Admin Plane Skeleton / 관리 Plane 스켈레톤 - -- DomainService 인터페이스: [`internal/admin/service.go`](internal/admin/service.go) - - `RegisterDomain(ctx, domain, memo) (clientAPIKey string, err error)` - - `UnregisterDomain(ctx, domain, clientAPIKey string) error` - -- HTTP Handler: [`internal/admin/http.go`](internal/admin/http.go) - - `Authorization: Bearer {ADMIN_API_KEY}` 검증. - - 엔드포인트: - - `POST /api/v1/admin/domains/register` - - `POST /api/v1/admin/domains/unregister` - - JSON request/response 구조 정의 및 기본 에러 처리. - - 실제 서비스(`DomainService`) 및 라우터 wiring, ent 기반 구현이 완료되어 도메인 등록/해제가 동작. - ---- - -### 2.6 DB / ent - -- ent 스키마: [`ent/schema/domain.go`](ent/schema/domain.go) - - `Domain` entity: - - `id` (UUID, PK) - - `domain` (unique) - - `client_api_key` (unique, max 64) - - `memo`, `created_at`, `updated_at`. - -- ent 코드 생성 완료: [`tools/gen_ent.sh`](tools/gen_ent.sh), [`ent/*`](ent/) - - PostgreSQL dialect 사용. - - `client.Schema.Create(ctx)` 로 테이블 자동 생성(DB init). - -- PostgreSQL 연결 헬퍼: [`internal/store/postgres.go`](internal/store/postgres.go) - - `OpenPostgres(ctx, logger, cfg)` - - `ent/dialect/sql.Open("postgres", DSN)` - - pool 설정, ping, ent.Driver wrapping, `Schema.Create`. - - `OpenPostgresFromEnv(ctx, logger)` - - 서버에서 바로 호출 가능. - ---- - -### 2.7 Logging / Build / Docker - -- 구조적 로깅: [`internal/logging/logging.go`](internal/logging/logging.go) - - JSON 단일라인 로그, `level`, `ts`, `msg`, `Fields`. - - Loki/Promtail + Grafana 스택에 최적화. - -- 빌드/도커: - - [`Makefile`](Makefile) — `make server`, `make client`, `make docker-server`. - - `server` 타겟은 Tailwind 기반 에러 페이지 CSS 빌드를 위한 `errors-css` 타겟을 선행 실행 (`npm run build:errors-css`). - - [`Dockerfile.server`](Dockerfile.server) — multi-stage build, Alpine runtime. - - Build stage 에 Node.js + npm 을 설치하고, `npm install && npm run build:errors-css` 를 통해 에러 페이지용 CSS를 빌드한 뒤 Go 서버 바이너리를 생성. - - [`.dockerignore`](.dockerignore) — `images/` 제외. - -- 아키텍처 이미지: [`images/architecture.jpeg`](images/architecture.jpeg) - ---- - -### 2.8 Error Pages / 에러 페이지 - -- 에러 페이지 템플릿: [`internal/errorpages/templates/*.html`](internal/errorpages/templates/400.html) - - HTTP 상태 코드별 HTML: - - `400.html`, `404.html`, `500.html`, `525.html`. - - TailwindCSS 기반 레이아웃 및 스타일 적용 (영문/한글 메시지 병기). - - `go:embed` 로 서버 바이너리에 포함되어 기본값으로 사용. - -- 에러 페이지 정적 에셋: [`internal/errorpages/assets`](internal/errorpages/errorpages.go) - - TailwindCSS 빌드 결과: `errors.css` (내장 CSS). - - 로고 등 브랜드 리소스: `logo.svg` 등 (내장 가능). - - 런타임에서는 `/__hopgate_assets__/...` prefix 로 HopGate 서버가 직접 서빙: - - 1순위: `HOP_ERROR_ASSETS_DIR` 가 설정된 경우 해당 디렉터리에서 정적 파일 로드. - - 2순위: 설정되지 않은 경우 `internal/errorpages/assets` 에 embed 된 에셋 사용. - -- 에러 페이지 렌더링 로직: [`internal/errorpages/errorpages.go`](internal/errorpages/errorpages.go), [`cmd/server/main.go`](cmd/server/main.go) - - `writeErrorPage(w, r, status)` → `errorpages.Render` 호출. - - HTML 로딩 우선순위: - - 1) `HOP_ERROR_PAGES_DIR/.html` (env 미설정 시 `./errors/.html`) - - 2) `internal/errorpages/templates/.html` (go:embed 기본 템플릿) - - 주요 사용처: - - 잘못된 ACME HTTP-01 요청 (400/404). - - 허용되지 않은 Host 요청 (404). - - DTLS 세션 부재/포워딩 실패 → 525 TLS/DTLS Handshake Failed 페이지. - ---- - -## 3. Remaining Work / 남은 작업 - -### 3.1 Admin Plane Implementation / 관리 Plane 구현 - -- [x] DomainService 실제 구현 추가: [`internal/admin/service.go`](internal/admin/service.go) - - ent.Client + PostgreSQL 기반 `RegisterDomain` / `UnregisterDomain` 구현. - - domain + client_api_key 유효성 검증 로직 포함. - -- [x] Admin API와 서버 라우터 연결: [`cmd/server/main.go`](cmd/server/main.go) - - `http.ServeMux` 혹은 router에 `admin.Handler.RegisterRoutes` 연결. - - Admin API용 HTTP/HTTPS 엔드포인트 구성. - -- [x] Admin API 키 관리 - - env 혹은 설정에 `ADMIN_API_KEY` 추가 및 로딩. - - Admin Handler에 주입. - ---- - -### 3.2 DomainValidator Implementation / DomainValidator 구현 - -- [x] `DomainValidator` 의 실제 구현 추가 (예: `internal/admin/domain_validator.go`). - - ent.Client 를 사용해 `Domain` 테이블 조회. - - `(domain, client_api_key)` 조합 검증. - - DummyDomainValidator 를 실제 구현으로 교체. - -- [x] DTLS Handshake 와 Admin Plane 통합 - - Admin Plane 에서 관리하는 Domain 테이블을 사용해, 핸드셰이크 시 `(domain, client_api_key)` 조합을 DB 기준으로 검증. - - 도메인 문자열은 `"host"` 또는 `"host:port"` 형태 모두 허용하되, DB 조회용 canonical 도메인에서는 host 부분만 사용. - ---- - -### 3.3 Proxy Core / gRPC Tunneling - -HopGate 의 최종 목표는 **TCP + TLS(HTTPS) + HTTP/2 + gRPC** 기반 터널로 HTTP 트래픽을 전달하는 것입니다. -이 섹션에서는 DTLS 기반 초기 설계를 정리만 남기고, 실제 구현/남은 작업은 gRPC 터널 기준으로 재정의합니다. - -- [x] 서버 측 gRPC 터널 엔드포인트 설계/구현 - - 외부 사용자용 HTTPS(443/TCP)와 같은 포트에서: - - 일반 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] 클라이언트 측 gRPC 터널 설계/구현 - - 클라이언트 프로세스는 HopGate 서버로 장기 유지 bi-di gRPC 스트림을 **하나(또는 소수 개)** 연 상태로 유지합니다. - - 서버로부터 들어오는 `TunnelFrame`(요청 메타데이터 + 바디 chunk)을 수신해, - 로컬 HTTP 서비스(예: `127.0.0.1:8080`)로 proxy 하고, 응답을 다시 `TunnelFrame` 시퀀스로 전송합니다. - - 기존 `internal/proxy/client.go` 의 HTTP 매핑/스트림 ARQ 경험을, gRPC 메시지 단위 chunk/flow-control 설계에 참고합니다. - -- [x] HTTP ↔ gRPC 터널 매핑 규약 정의 - - 한 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 이 필요하면 최소한으로만 도입합니다. - -- [ ] gRPC 터널 기반 E2E 플로우 정의/테스트 계획 - - 하나의 gRPC 스트림 위에서: - - 동시에 여러 정적 리소스(`/css`, `/js`, `/img`) 요청, - - 큰 응답(수 MB 파일)과 작은 응답(API JSON)이 섞여 있는 시나리오, - - 클라이언트 재시작/네트워크 단절 후 재연결 시나리오 - 를 포함하는 테스트 플랜을 작성합니다. - - 기대 동작: - - 느린 요청이 있더라도 다른 요청이 **같은 TCP 연결/스트림 집합 내에서** 과도하게 지연되지 않을 것. - - 서버/클라이언트 로그에 프로토콜 위반 경고(`unexpected frame ...`)가 발생하지 않을 것. - -> Note: 기존 DTLS 기반 스트림/ARQ/멀티플렉싱(3.3A/3.3B)의 작업 내역은 -> 구현 경험/아이디어 참고용으로만 유지하며, 신규 기능/운영 계획은 gRPC 터널을 기준으로 진행합니다. - ---- - -### 3.4 ACME Integration / ACME 연동 - -- [x] [`internal/acme/acme.go`](internal/acme/acme.go) 실제 구현 - - lego 기반 ACME 매니저 구현. - - 메인 도메인 + 프록시 도메인용 인증서 발급/갱신. - - HTTP-01 챌린지 처리(webroot 방식). - -- [x] 서버 main 에 ACME 기반 `*tls.Config` 주입 - - DTLS / HTTPS 리스너에 ACME 인증서 적용 (Debug 모드에서는 DTLS 에 self-signed, HTTPS 에 ACME 사용). - -- [ ] ACME 고급 기능 및 운영 전략 보완 - - TLS-ALPN-01 챌린지 지원 여부 검토 및 필요 시 lego 설정/핸들러 추가. - - 인증서 발급/갱신 실패 시 재시도/백오프 및 경고 로그/알림을 포함한 에러 처리 전략 정의. - - Debug(스테이징 CA) / Production(실 CA) 환경 전환 플로우와 도메인/환경별 ACME 설정 매트릭스를 문서화. - ---- - -### 3.5 Observability / 관측성 - -- [x] Prometheus 메트릭 노출 및 서버 wiring - - `cmd/server/main.go` 에 Prometheus `/metrics` 엔드포인트 추가 (예: promhttp.Handler). - - DTLS 핸드셰이크 성공/실패 수, HTTP 요청 수, HTTP 요청 지연, Proxy 에러 수에 대한 메트릭을 정의합니다. - - 메트릭 라벨은 메서드/상태 코드/결과/에러 타입 등에 한정되며, 도메인/클라이언트 ID/request_id 는 구조적 로그 필드로만 노출됩니다. - -- [ ] Loki/Grafana 대시보드 및 쿼리 예시 - - Loki/Promtail 구성을 가정한 주요 로그 쿼리 예시 정리(도메인, 클라이언트 ID, request_id 기준). - - Prometheus 메트릭 기반 기본 대시보드 템플릿 작성 (DTLS 상태, 프록시 트래픽, 에러율 등). - ---- - -### 3.6 Hardening / 안정성 & 구성 - -- [x] 설정 유효성 검사 추가 - - 필수 env 누락/오류에 대한 명확한 에러 메시지. - -- [ ] 에러 처리/재시도 정책 - - DTLS 재연결, Proxy 재시도, DB 재시도 정책 정의. - -- [ ] 보안 검토 - - Admin API 인증 방식 재검토 (예: IP allowlist, 추가 인증 수단). - - 클라이언트 API Key 저장/회전 전략. - -- [ ] Proxy 서버 추상화 및 Router 리팩터링 - - `internal/proxy/server.go` 의 `ServerProxy` 및 `Router` 인터페이스를 실제 HTTP ↔ DTLS 터널링 경로에 적용. - - 현재 `cmd/server/main.go` 에 위치한 Proxy 코어 로직을 proxy 레이어로 이동. - ---- - -## 4. Milestones / 마일스톤 - -### Milestone 1 — DTLS Handshake + Admin + DB (기본 인증 토대) - -- [x] DTLS transport & handshake skeleton 구현 (server/client). -- [x] Domain ent schema + PostgreSQL 연결 & schema init. -- [x] DomainService 실제 구현 + DomainValidator 구현. -- [x] Admin API + ent + PostgreSQL 연결 (실제 도메인 등록/해제 동작). - -### Milestone 2 — Full HTTP Tunneling (프락시 동작 완성) - -- [x] 서버 Proxy 코어 구현 및 HTTPS ↔ DTLS 라우팅. - - 현재 `cmd/server/main.go` 의 `newHTTPHandler` / `dtlsSessionWrapper.ForwardHTTP` 경로에서 동작합니다. -- [x] 클라이언트 Proxy 루프 구현 및 로컬 서비스 연동. - - `cmd/client/main.go` + [`ClientProxy.StartLoop()`](internal/proxy/client.go:59) 를 통해 DTLS 세션 위에서 로컬 서비스와 연동됩니다. -- [ ] End-to-end HTTP 요청/응답 터널링 E2E 테스트. - -### Milestone 3 — ACME + TLS/DTLS 정식 인증 - -- [x] ACME 매니저 구현 (lego 기반). -- [x] HTTPS/DTLS 리스너에 ACME 인증서 주입. -- [ ] ACME 고급 기능 및 운영 전략 정리 (예: TLS-ALPN-01, 인증서 롤오버/장애 대응 전략). - -### Milestone 4 — Observability & Hardening - -- [ ] Prometheus/Loki/Grafana 통합. - - Prometheus 메트릭 정의 및 `/metrics` 엔드포인트는 이미 구현 및 동작 중이며, - Loki/Promtail/Grafana 대시보드 및 운영 통합 작업은 아직 남아 있습니다. - -- [ ] 에러/리트라이/타임아웃 정책 정교화. -- [ ] 보안/구성 최종 점검 및 문서화. - ---- - -이 `progress.md` 파일은 아키텍처/코드 변경에 따라 수시로 업데이트하며, Milestone 기준으로 완료 여부를 체크해 나가면 된다. -This `progress.md` file should be updated as the architecture and code evolve, using the milestones above as a checklist. \ No newline at end of file diff --git a/protocol.md b/protocol.md deleted file mode 100644 index b2581ad..0000000 --- a/protocol.md +++ /dev/null @@ -1,197 +0,0 @@ -# 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` -- `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 보안을 함께 유지할 수 있습니다. \ No newline at end of file