mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2026-09-21 08:11:06 +09:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd8463c19 | ||
|
|
fe1018469d | ||
|
|
8e2f1e68cb | ||
|
|
983332b3d8 | ||
|
|
38f05db0dc | ||
|
|
a41bd34179 | ||
|
|
e388e5a272 | ||
|
|
d93440f4b3 | ||
|
|
1492a1a82c | ||
|
|
64f730d2df | ||
|
|
17839def69 | ||
|
|
faea425e57 |
+5
-5
@@ -41,8 +41,8 @@ HOP_SERVER_HTTP_LISTEN=:8080
|
|||||||
# HTTPS 리스닝 포트 (보통 :443)
|
# HTTPS 리스닝 포트 (보통 :443)
|
||||||
HOP_SERVER_HTTPS_LISTEN=:8443
|
HOP_SERVER_HTTPS_LISTEN=:8443
|
||||||
|
|
||||||
# DTLS 리스닝 포트 (보통 :443, 필요시 별도 포트 사용)
|
# TLS + yamux 클라이언트 터널 포트
|
||||||
HOP_SERVER_DTLS_LISTEN=:8443
|
HOP_SERVER_TUNNEL_LISTEN=:7443
|
||||||
|
|
||||||
# 메인 도메인 (예: example.com)
|
# 메인 도메인 (예: example.com)
|
||||||
HOP_SERVER_DOMAIN=example.com
|
HOP_SERVER_DOMAIN=example.com
|
||||||
@@ -102,9 +102,9 @@ HOP_DB_DSN=postgres://user:pass@localhost:5432/hopgate?sslmode=disable
|
|||||||
|
|
||||||
# ---- Client settings ----
|
# ---- Client settings ----
|
||||||
|
|
||||||
# DTLS 서버 주소 (host:port)
|
# yamux 터널 서버 주소 (host:port)
|
||||||
# 예: example.com:443
|
# 예: example.com:7443
|
||||||
HOP_CLIENT_SERVER_ADDR=localhost:8443
|
HOP_CLIENT_SERVER_ADDR=localhost:7443
|
||||||
|
|
||||||
# 클라이언트 도메인
|
# 클라이언트 도메인
|
||||||
HOP_CLIENT_DOMAIN=test.example.com
|
HOP_CLIENT_DOMAIN=test.example.com
|
||||||
|
|||||||
@@ -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.
|
## Public Ingress
|
||||||
이 문서는 현재 HopGate에 구현된 외부 공개 API를 정리한 것으로, 영어를 기본으로 하며 한국어 설명을 병기합니다.
|
|
||||||
|
|
||||||
---
|
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.
|
HTTP/3 is announced to HTTP/1.1 and HTTP/2 clients with `Alt-Svc`. HTTP/2
|
||||||
관리 Plane은 HopGate 서버의 HTTPS 엔드포인트 아래에서 동작합니다.
|
Extended CONNECT requires `GODEBUG=http2xconnect=1` when starting the server.
|
||||||
|
|
||||||
- Base URL: `https://{HOP_SERVER_DOMAIN}/api/v1/admin`
|
### SSE
|
||||||
기본 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)
|
|
||||||
|
|
||||||
### 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}`
|
```text
|
||||||
헤더: `Authorization: Bearer {HOP_ADMIN_API_KEY}`
|
data: hello
|
||||||
- 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` 로 응답합니다.
|
|
||||||
|
|
||||||
### 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)
|
HTTP/1.1 WebSocket uses `Upgrade: websocket`. HTTP/2 and HTTP/3 use Extended
|
||||||
- FQDN, must contain at least one dot, case-insensitive.
|
CONNECT with `:protocol=websocket`. The local service may remain an ordinary
|
||||||
- 공백이 없어야 하며, 최소 한 개 이상의 점(`.`)을 포함하는 FQDN이어야 합니다.
|
HTTP/1.1 WebSocket server; HopGate translates the Extended CONNECT handshake
|
||||||
- `memo` (string, optional)
|
before relaying the raw bidirectional payload.
|
||||||
- Free-form memo for administrators; may be empty.
|
|
||||||
- 관리자를 위한 자유 형식 메모로, 비어 있어도 됩니다.
|
|
||||||
|
|
||||||
#### 1.2.2 Successful Response / 성공 응답
|
## Admin API
|
||||||
|
|
||||||
- Status: `200 OK`
|
Admin endpoints are served under `/api/v1/admin/` on `HOP_SERVER_DOMAIN` and
|
||||||
- Body:
|
require `Authorization: Bearer $HOP_ADMIN_API_KEY`.
|
||||||
|
|
||||||
```json
|
- `POST /api/v1/admin/domains/register`
|
||||||
{
|
- Request: `{"domain":"app.example.com","memo":"optional"}`
|
||||||
"success": true,
|
- Response includes the generated `client_api_key`.
|
||||||
"client_api_key": "abcd1234...wxyz5678"
|
- `POST /api/v1/admin/domains/unregister`
|
||||||
}
|
- Request: `{"domain":"app.example.com","client_api_key":"..."}`
|
||||||
```
|
|
||||||
|
|
||||||
- Fields
|
## Tunnel Configuration
|
||||||
필드
|
|
||||||
|
|
||||||
- `success` (boolean) — always `true` on success.
|
The server listens for client tunnels on `HOP_SERVER_TUNNEL_LISTEN`, defaulting
|
||||||
`success` (boolean) — 성공 시 항상 `true` 입니다.
|
to `:7443`. The client connects to that address with
|
||||||
- `client_api_key` (string, length 64) — client API key bound to the registered domain.
|
`HOP_CLIENT_SERVER_ADDR`. The client only needs an outbound TCP connection.
|
||||||
`client_api_key` (string, 길이 64) — 등록된 도메인에 매핑된 클라이언트 API 키입니다.
|
|
||||||
|
|
||||||
#### 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`
|
The tunnel is TLS over TCP with yamux multiplexing. The client only needs an
|
||||||
- Invalid JSON body or missing/empty `domain`.
|
outbound TCP connection to the tunnel listener; public HTTP/3 traffic terminates
|
||||||
- JSON 바디가 잘못되었거나 `domain` 이 비어 있는 경우.
|
at the server and does not require QUIC support in the client.
|
||||||
- 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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
+58
-216
@@ -1,237 +1,79 @@
|
|||||||
# HopGate Architecture / HopGate 아키텍처
|
# HopGate Architecture
|
||||||
|
|
||||||
이 문서는 HopGate 시스템의 전체 구조를 설명합니다. (ko)
|
HopGate exposes public HTTP traffic and forwards it to a private HTTP service
|
||||||
This document describes the overall architecture of the HopGate system. (en)
|
through one outbound TLS connection per client.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
- 클라이언트는 DTLS를 통해 서버에 연결되고, 서버가 전달한 HTTP 요청을 로컬 서비스(127.0.0.1:PORT)에 대신 보내고 응답을 다시 서버로 전달합니다. (ko)
|
|
||||||
- Clients connect to the server via DTLS, forward HTTP requests to local services (127.0.0.1:PORT), and send the responses back to the server. (en)
|
|
||||||
|
|
||||||
- 관리 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 / 디렉터리 레이아웃
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
TCP :80/:443 HTTP/1.1, HTTP/2
|
||||||
├── cmd/
|
public clients -------------------------------> HopGate server
|
||||||
│ ├── server/ # server binary entrypoint
|
UDP :443 HTTP/3 |
|
||||||
│ └── client/ # client binary entrypoint
|
| TLS/TCP
|
||||||
├── internal/
|
v
|
||||||
│ ├── config/ # shared configuration loader
|
yamux logical streams
|
||||||
│ ├── acme/ # ACME certificate management
|
|
|
||||||
│ ├── dtls/ # DTLS abstraction & implementation
|
v
|
||||||
│ ├── proxy/ # HTTP proxy / tunneling core
|
HopGate client
|
||||||
│ ├── protocol/ # server-client message protocol
|
|
|
||||||
│ ├── admin/ # admin plane HTTP handlers
|
v
|
||||||
│ └── logging/ # structured logging utilities
|
localhost HTTP
|
||||||
├── ent/
|
|
||||||
│ └── schema/ # ent schema definitions (e.g. Domain)
|
|
||||||
└── pkg/
|
|
||||||
└── util/ # reusable helpers (optional)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 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/DTLS 리스너 시작을 담당합니다. (ko)
|
Each public request creates one bidirectional yamux stream. Every stream starts
|
||||||
- [`cmd/server/main.go`](cmd/server/main.go) — Server entrypoint. Loads configuration, initializes ACME/TLS, and starts HTTP/HTTPS/DTLS listeners. (en)
|
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) — 클라이언트 실행 엔트리 포인트. 설정 로딩, DTLS 연결 및 핸드셰이크, 로컬 서비스 프록시 루프를 담당합니다. (ko)
|
- `control`: client registration and authentication metadata.
|
||||||
- [`cmd/client/main.go`](cmd/client/main.go) — Client entrypoint. Loads configuration, performs DTLS connection and handshake, and runs the local proxy loop. (en)
|
- `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)
|
The public server uses one common `http.Handler` for all ingress protocols:
|
||||||
- Provides shared config structs for server and client, plus `.env`/environment variable loaders. (en)
|
|
||||||
|
|
||||||
- 주요 구조체 / Main structs: (ko/en)
|
- HTTP/1.1: ordinary reverse proxy and raw WebSocket Upgrade.
|
||||||
- `ServerConfig` — HTTP/HTTPS/DTLS 리스닝 주소, 도메인/프록시 도메인, Debug 플래그, 로그 설정. (ko)
|
- HTTP/2: ordinary reverse proxy, SSE, and RFC 8441 Extended CONNECT.
|
||||||
- `ServerConfig` — HTTP/HTTPS/DTLS listen addresses, main/proxy domains, debug flag, logging config. (en)
|
- HTTP/3: ordinary reverse proxy, SSE, and RFC 9220 Extended CONNECT.
|
||||||
- `ClientConfig` — 서버 주소, 도메인, 클라이언트 API Key, local_target, Debug 플래그, 로그 설정. (ko)
|
|
||||||
- `ClientConfig` — server address, domain, client API key, local_target, debug flag, logging config. (en)
|
|
||||||
|
|
||||||
---
|
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)
|
```bash
|
||||||
- Package that will wrap an ACME client (e.g. Let's Encrypt) and manage certificates. (en)
|
GODEBUG=http2xconnect=1 ./bin/hop-gate-server
|
||||||
|
```
|
||||||
|
|
||||||
- 역할 / Responsibilities: (ko/en)
|
## Streaming Policies
|
||||||
- 메인 도메인 및 프록시 서브도메인용 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/DTLS 리스너에 사용할 `*tls.Config` 제공. (ko)
|
|
||||||
- Provide `*tls.Config` for HTTPS/DTLS listeners. (en)
|
|
||||||
|
|
||||||
---
|
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 `internal/dtls`
|
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 통신을 추상화하고, pion/dtls 기반 구현 및 핸드셰이크 로직을 포함합니다. (ko)
|
## Packages
|
||||||
- Abstracts DTLS communication and includes a pion/dtls-based implementation plus handshake logic. (en)
|
|
||||||
|
|
||||||
- 주요 요소 / Main elements: (ko/en)
|
- `internal/tunnel`: TLS dialing, yamux sessions, metadata, and stream lifecycle.
|
||||||
- `Session`, `Server`, `Client` 인터페이스 — DTLS 위의 스트림과 서버/클라이언트를 추상화. (ko)
|
- `cmd/server`: public HTTP/HTTPS/HTTP/3 ingress and yamux tunnel listener.
|
||||||
- `Session`, `Server`, `Client` interfaces — abstract streams and server/client roles over DTLS. (en)
|
- `cmd/client`: outbound yamux client and local HTTP/WebSocket forwarding.
|
||||||
- `NewPionServer`, `NewPionClient` — pion/dtls 를 사용하는 실제 구현. (ko)
|
- `internal/admin`: domain registration and API-key validation.
|
||||||
- `NewPionServer`, `NewPionClient` — concrete implementations using pion/dtls. (en)
|
- `internal/acme`: certificate acquisition, renewal, and TLS configuration.
|
||||||
- `PerformServerHandshake`, `PerformClientHandshake` — 도메인 + 클라이언트 API Key 기반 애플리케이션 레벨 핸드셰이크. (ko)
|
|
||||||
- `PerformServerHandshake`, `PerformClientHandshake` — application-level handshake based on domain + client API key. (en)
|
|
||||||
- `NewSelfSignedLocalhostConfig` — 디버그용 localhost self-signed TLS 설정을 생성. (ko)
|
|
||||||
- `NewSelfSignedLocalhostConfig` — generates a debug-only localhost self-signed TLS config. (en)
|
|
||||||
|
|
||||||
---
|
The tunnel is intentionally stream-oriented. It does not implement application
|
||||||
|
ACKs or retransmission; TLS over TCP and yamux provide ordered reliable delivery.
|
||||||
### 2.5 `internal/protocol`
|
|
||||||
|
|
||||||
- 서버와 클라이언트가 DTLS 위에서 주고받는 HTTP 요청/응답 메시지 포맷을 정의합니다. (ko)
|
|
||||||
- Defines HTTP request/response message formats exchanged over DTLS between server and clients. (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)
|
|
||||||
|
|
||||||
- 인코딩은 현재 JSON 을 사용하며, 각 HTTP 요청/응답을 하나의 Envelope 로 감싸 DTLS 위에서 전송합니다. (ko)
|
|
||||||
- Encoding currently uses JSON, wrapping each HTTP request/response in a single Envelope sent over DTLS. (en)
|
|
||||||
|
|
||||||
- 향후에는 `Envelope.StreamOpen` / `StreamData` / `StreamClose` 필드를 활용한 **스트림/프레임 기반 프로토콜**로 전환하여,
|
|
||||||
대용량 HTTP 바디도 DTLS/UDP MTU 한계를 넘지 않도록 chunk 단위로 안전하게 전송할 계획입니다. (ko)
|
|
||||||
- In the future, the plan is to move to a **stream/frame-based protocol** using `Envelope.StreamOpen` / `StreamData` / `StreamClose`,
|
|
||||||
so that large HTTP bodies can be safely chunked under DTLS/UDP MTU limits. (en)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
- 요청을 `protocol.Request` 로 직렬화하여 DTLS 세션을 통해 클라이언트로 전송합니다. (ko)
|
|
||||||
- Serialize the request as `protocol.Request` and send it over a DTLS session to the client. (en)
|
|
||||||
|
|
||||||
- 클라이언트로부터 받은 `protocol.Response` 를 HTTP 응답으로 복원하여 외부 사용자에게 반환합니다. (ko)
|
|
||||||
- Deserialize `protocol.Response` from the client and return it as an HTTP response to the external user. (en)
|
|
||||||
|
|
||||||
#### 클라이언트 측 역할 / Client-side role
|
|
||||||
|
|
||||||
- DTLS 채널을 통해 서버가 내려보낸 `protocol.Request` 를 수신합니다. (ko)
|
|
||||||
- Receive `protocol.Request` objects sent by the server over DTLS. (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)
|
|
||||||
|
|
||||||
- 응답을 `protocol.Response` 로 직렬화하여 DTLS 채널을 통해 서버로 전송합니다. (ko)
|
|
||||||
- Serialize responses as `protocol.Response` and send them back to the server over DTLS. (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. 서버는 요청을 `protocol.Request` 구조로 직렬화하고, `dtls.Session` 을 통해 선택된 클라이언트로 전송합니다. (ko)
|
|
||||||
The server serializes the request into a `protocol.Request` and sends it to the selected client over a `dtls.Session`. (en)
|
|
||||||
|
|
||||||
5. 클라이언트의 `proxy` 레이어는 `protocol.Request` 를 수신하고, 로컬 서비스(예: 127.0.0.1:8080)에 HTTP 요청을 수행합니다. (ko)
|
|
||||||
The client’s `proxy` layer receives the `protocol.Request` and performs an HTTP request to a local service (e.g., 127.0.0.1:8080). (en)
|
|
||||||
|
|
||||||
6. 클라이언트는 로컬 서비스로부터 HTTP 응답을 수신하고, 이를 `protocol.Response` 로 직렬화하여 DTLS 채널을 통해 서버로 다시 전송합니다. (ko)
|
|
||||||
The client receives the HTTP response from the local service, serializes it as a `protocol.Response`, and sends it back to the server over DTLS. (en)
|
|
||||||
|
|
||||||
7. 서버는 `protocol.Response` 를 디코딩하여 원래의 HTTPS 요청에 대한 HTTP 응답으로 변환한 뒤, 외부 사용자에게 반환합니다. (ko)
|
|
||||||
The server decodes the `protocol.Response`, converts it back into an HTTP response, and returns it to the original external user. (en)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
- `internal/dtls` 에서 pion/dtls 기반 DTLS 전송 계층 및 핸드셰이크를 안정화합니다. (ko)
|
|
||||||
- Stabilize the pion/dtls-based DTLS transport and handshake logic in `internal/dtls`. (en)
|
|
||||||
|
|
||||||
- `internal/protocol` 과 `internal/proxy` 를 통해 실제 HTTP 터널링을 구현하고,
|
|
||||||
단일 JSON Envelope 기반 모델에서 `StreamOpen` / `StreamData` / `StreamClose` 중심의 스트림 기반 DTLS 터널링으로 전환합니다. (ko)
|
|
||||||
- Implement real HTTP tunneling and routing rules via `internal/protocol` and `internal/proxy`,
|
|
||||||
and move from a single JSON-Envelope model to a stream-based DTLS tunneling model built around `StreamOpen` / `StreamData` / `StreamClose`. (en)
|
|
||||||
|
|
||||||
- `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)
|
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@
|
|||||||
# hop-gate-server:dev
|
# hop-gate-server:dev
|
||||||
|
|
||||||
# ---------- Build stage ----------
|
# ---------- Build stage ----------
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
# BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다.
|
# BuildKit / buildx 가 제공하는 타겟 OS/ARCH 인자를 사용해 멀티 아키텍처 빌드를 지원합니다.
|
||||||
# 기본값을 지정해두면 로컬 docker build 시에도 별도 인자 없이 빌드 가능합니다.
|
# 기본값을 지정해두면 로컬 docker build 시에도 별도 인자 없이 빌드 가능합니다.
|
||||||
@@ -38,7 +38,7 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-X main.version=${VERSION}" -o /out/hop-gate-server ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-X main.version=${VERSION}" -o /out/hop-gate-server ./cmd/server
|
||||||
|
|
||||||
# ---------- Runtime stage ----------
|
# ---------- Runtime stage ----------
|
||||||
FROM alpine:3.20
|
FROM alpine:3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ COPY --from=builder /out/hop-gate-server /app/hop-gate-server
|
|||||||
COPY .env.example /app/.env.example
|
COPY .env.example /app/.env.example
|
||||||
|
|
||||||
# 기본 포트 노출 (실제 포트는 .env / 설정에 따라 변경 가능)
|
# 기본 포트 노출 (실제 포트는 .env / 설정에 따라 변경 가능)
|
||||||
EXPOSE 80 443/udp 443
|
EXPOSE 80 443
|
||||||
|
|
||||||
# 기본 실행 명령
|
# 기본 실행 명령
|
||||||
ENTRYPOINT ["/app/hop-gate-server"]
|
ENTRYPOINT ["/app/hop-gate-server"]
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ docker-server:
|
|||||||
check-env-server:
|
check-env-server:
|
||||||
@if [ -z "$$HOP_SERVER_HTTP_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTP_LISTEN이 설정되지 않았습니다."; exit 1; fi
|
@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_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
|
@if [ -z "$$HOP_SERVER_DOMAIN" ]; then echo "필수 환경 변수 HOP_SERVER_DOMAIN가 설정되지 않았습니다."; exit 1; fi
|
||||||
|
|
||||||
check-env-client:
|
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_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_LOCAL_TARGET" ]; then echo "필수 환경 변수 HOP_CLIENT_LOCAL_TARGET가 설정되지 않았습니다."; exit 1; fi
|
||||||
@if [ -z "$$HOP_CLIENT_DEBUG" ]; then echo "필수 환경 변수 HOP_CLIENT_DEBUG가 설정되지 않았습니다."; 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."
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
|
|
||||||
## 1. 프로젝트 개요 (Project Overview)
|
## 1. 프로젝트 개요 (Project Overview)
|
||||||
|
|
||||||
HopGate는 공인 서버와 여러 프라이빗 네트워크 클라이언트 사이에 **DTLS 기반 HTTP 터널**을 제공하는 게이트웨이입니다.
|
HopGate는 공인 서버와 여러 프라이빗 네트워크 클라이언트 사이에 **TLS + yamux 기반 HTTP 터널**을 제공하는 게이트웨이입니다.
|
||||||
HopGate is a gateway that provides a **DTLS-based HTTP tunnel** between a public server and multiple private-network clients.
|
HopGate is a gateway that provides a **TLS + yamux HTTP tunnel** between a public server and multiple private-network clients.
|
||||||
|
|
||||||
주요 특징 (Key features):
|
주요 특징 (Key features):
|
||||||
|
|
||||||
- 서버는 80/443 포트를 점유하고, ACME(Let's Encrypt 등)로 TLS 인증서를 자동 발급/갱신합니다.
|
- 서버는 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).
|
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** 로 터널링합니다.
|
- 서버–클라이언트 간 기본 전송은 TLS 위의 TCP와 yamux이며, 하나의 연결에 여러 HTTP logical stream을 multiplex합니다.
|
||||||
Transport between server and clients uses DTLS; HTTP requests/responses are tunneled as **Protobuf-based, length-prefixed envelopes**.
|
The default transport is TCP + TLS with yamux multiplexing, carrying multiple HTTP logical streams over one connection.
|
||||||
- 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다.
|
- 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다.
|
||||||
An admin management plane (REST API) handles domain registration/unregistration and client API key issuance.
|
An admin management plane (REST API) handles domain registration/unregistration and client API key issuance.
|
||||||
- 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다.
|
- 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다.
|
||||||
Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack.
|
Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack.
|
||||||
|
|
||||||
> 참고: 대용량 HTTP 바디에 대해서는 DTLS/UDP MTU 한계 때문에 **단일 Envelope** 로는 한계가 있으므로, `progress.md` 의 3.3A 섹션에 정리된 것처럼 `StreamOpen` / `StreamData` / `StreamClose` 기반의 스트림/프레임 터널링으로 점진적으로 전환할 예정입니다. (ko)
|
> 참고: yamux logical stream은 HTTP/1.1 wire format을 사용하지만, 요청과 응답 body는 버퍼 전체를 메모리에 올리지 않고 스트리밍됩니다. SSE는 연결이 유지되는 동안 이벤트를 즉시 전달합니다. (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)
|
> Note: yamux logical streams use HTTP/1.1 wire format, while request and response bodies are streamed without buffering the entire payload in memory. SSE events are delivered while the connection remains open. (en)
|
||||||
|
|
||||||
아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다.
|
아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다.
|
||||||
Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md).
|
Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||||
@@ -31,7 +31,7 @@ Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md).
|
|||||||
- 서버 엔트리 (Server entrypoint): [`cmd/server/main.go`](cmd/server/main.go)
|
- 서버 엔트리 (Server entrypoint): [`cmd/server/main.go`](cmd/server/main.go)
|
||||||
- 클라이언트 엔트리 (Client entrypoint): [`cmd/client/main.go`](cmd/client/main.go)
|
- 클라이언트 엔트리 (Client entrypoint): [`cmd/client/main.go`](cmd/client/main.go)
|
||||||
- 설정 로더 (Config loader): [`internal/config/config.go`](internal/config/config.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)
|
- 관리 Plane (Admin plane HTTP API): [`internal/admin`](internal/admin)
|
||||||
- 도메인 스키마 (Domain schema, ent): [`ent/schema/domain.go`](ent/schema/domain.go)
|
- 도메인 스키마 (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)
|
### 3.1 의존성 (Dependencies)
|
||||||
|
|
||||||
- Go 1.21+ 권장 (go.mod 상 버전보다 최신 Go 사용을 추천)
|
- Go 1.27.0+ 필요
|
||||||
Go 1.21+ is recommended (even if go.mod specifies an older minor).
|
Go 1.27.0 or newer is required.
|
||||||
- PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수)
|
- PostgreSQL (관리 Plane + 실제 DomainValidator 에 필수)
|
||||||
PostgreSQL (required for the admin plane and the real DomainValidator).
|
PostgreSQL (required for the admin plane and the real DomainValidator).
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Required environment variables are validated in two stages:
|
|||||||
|
|
||||||
2. **실행 단계 (Runtime) – 엔트리포인트에서 엄격 검증 (strict runtime validation)**
|
2. **실행 단계 (Runtime) – 엔트리포인트에서 엄격 검증 (strict runtime validation)**
|
||||||
- 서버: [`cmd/server/main.go`](cmd/server/main.go)
|
- 서버: [`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)와 함께 프로세스를 종료합니다.
|
- 누락되었거나 공백인 경우, 구조화 에러 로그(JSON)와 함께 프로세스를 종료합니다.
|
||||||
- 클라이언트: [`cmd/client/main.go`](cmd/client/main.go)
|
- 클라이언트: [`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` 를 동일한 방식으로 검증합니다.
|
- `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` 파일을 사용해 빠르게 설정을 구성할 수 있습니다.
|
로컬 개발에서는 `.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.
|
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는 TLS 연결 위의 yamux control stream에서 **도메인 + 클라이언트 API Key** 기반의 핸드셰이크를 수행합니다.
|
||||||
HopGate performs an application-level handshake over DTLS using **domain + client API key**.
|
HopGate authenticates a yamux control stream using **domain + client API key**.
|
||||||
|
|
||||||
### 4.1 서버 설정 예시 (Server .env example)
|
### 4.1 서버 설정 예시 (Server .env example)
|
||||||
|
|
||||||
`.env`:
|
`.env`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
HOP_SERVER_DTLS_LISTEN=:8443
|
HOP_SERVER_TUNNEL_LISTEN=:7443
|
||||||
HOP_SERVER_DEBUG=true
|
HOP_SERVER_DEBUG=true
|
||||||
```
|
```
|
||||||
|
|
||||||
- `HOP_SERVER_DTLS_LISTEN`
|
- `HOP_SERVER_TUNNEL_LISTEN`
|
||||||
DTLS 서버가 바인딩할 UDP 포트입니다. 예: `:8443`
|
TLS + yamux 서버가 바인딩할 TCP 포트입니다. 예: `:7443`
|
||||||
UDP port for the DTLS server to bind on, e.g. `:8443`.
|
TCP port for the TLS + yamux server to bind on, e.g. `:7443`.
|
||||||
- `HOP_SERVER_DEBUG=true`
|
- `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)
|
### 4.2 클라이언트 설정 예시 (Client .env example)
|
||||||
|
|
||||||
`.env`:
|
`.env`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
HOP_CLIENT_SERVER_ADDR=localhost:8443
|
HOP_CLIENT_SERVER_ADDR=localhost:7443
|
||||||
HOP_CLIENT_DOMAIN=test.example.com
|
HOP_CLIENT_DOMAIN=test.example.com
|
||||||
HOP_CLIENT_API_KEY=TEST_LOCALHOST_API_KEY_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
HOP_CLIENT_API_KEY=TEST_LOCALHOST_API_KEY_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||||
HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080
|
HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080
|
||||||
HOP_CLIENT_DEBUG=true
|
HOP_CLIENT_DEBUG=true
|
||||||
```
|
```
|
||||||
|
|
||||||
- `HOP_CLIENT_SERVER_ADDR` : DTLS 서버 주소 (예: `localhost:8443`)
|
- `HOP_CLIENT_SERVER_ADDR` : yamux 터널 서버 주소 (예: `localhost:7443`)
|
||||||
DTLS server address, e.g. `localhost:8443`.
|
yamux tunnel server address, e.g. `localhost:7443`.
|
||||||
- `HOP_CLIENT_DOMAIN` / `HOP_CLIENT_API_KEY` : 관리 Plane 에서 발급받은 도메인/키 (실제 ent + PostgreSQL 기반 DomainValidator 에 의해 검증)
|
- `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).
|
Domain and API key issued by the admin plane (validated by a real ent + PostgreSQL based DomainValidator).
|
||||||
- `HOP_CLIENT_LOCAL_TARGET` : 실제로 HTTP 요청을 보낼 로컬 서버 주소
|
- `HOP_CLIENT_LOCAL_TARGET` : 실제로 HTTP 요청을 보낼 로컬 서버 주소
|
||||||
@@ -169,6 +169,17 @@ HOP_CLIENT_DEBUG=true
|
|||||||
./bin/hop-gate-client
|
./bin/hop-gate-client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
HTTP/3 ingress를 사용하려면 서버의 TCP HTTPS 포트와 동일한 UDP 포트를 외부에 노출해야 합니다.
|
||||||
|
HTTP/3 ingress requires exposing the same port as the HTTPS listener over UDP.
|
||||||
|
|
||||||
|
HTTP/2 Extended CONNECT를 사용하는 클라이언트가 있는 경우 Go HTTP/2의
|
||||||
|
호환성 설정을 켜고 서버를 실행합니다.
|
||||||
|
For HTTP/2 Extended CONNECT clients, enable Go's compatibility setting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GODEBUG=http2xconnect=1 ./bin/hop-gate-server
|
||||||
|
```
|
||||||
|
|
||||||
성공 시 로그에는 다음과 같은 정보가 찍힙니다.
|
성공 시 로그에는 다음과 같은 정보가 찍힙니다.
|
||||||
On success, logs will include information like:
|
On success, logs will include information like:
|
||||||
|
|
||||||
@@ -210,12 +221,37 @@ For implementation skeleton, see [`internal/admin`](internal/admin) and [`ent/sc
|
|||||||
|
|
||||||
- `Debug=true` 설정은 **개발/테스트 용도**입니다. self-signed 인증서 및 InsecureSkipVerify 사용은 프로덕션 환경에서 절대 사용하지 마세요.
|
- `Debug=true` 설정은 **개발/테스트 용도**입니다. self-signed 인증서 및 InsecureSkipVerify 사용은 프로덕션 환경에서 절대 사용하지 마세요.
|
||||||
`Debug=true` is strictly for development/testing. Do not use self-signed certs or InsecureSkipVerify in production.
|
`Debug=true` is strictly for development/testing. Do not use self-signed certs or InsecureSkipVerify in production.
|
||||||
- 현재 버전은 ACME 기반 인증서, PostgreSQL + ent 기반 DomainValidator, Proxy 레이어가 기본적으로 연동되어 있으나,
|
- 현재 yamux 경로는 HTTP/1.1·HTTP/2·HTTP/3 공개 요청, SSE, HTTP/1.1 WebSocket raw upgrade와 HTTP/2·HTTP/3 Extended CONNECT를 처리합니다.
|
||||||
대용량 HTTP 바디에 대해서는 JSON 단일 메시지 기반 터널링 특성상 DTLS/UDP MTU 한계에 부딪힐 수 있습니다.
|
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.
|
||||||
스트림/프레임 기반 DTLS 터널링으로의 전환 및 하드닝 작업은 `progress.md` 에 정의된 다음 단계에 포함되어 있습니다. (ko)
|
|
||||||
The current version wires ACME certificates, a PostgreSQL+ent-based DomainValidator, and the proxy layer by default,
|
### Supported Ingress Protocols
|
||||||
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)
|
| Ingress | 일반 HTTP | SSE | WebSocket 방식 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| HTTP/1.1 | 지원 | 지원 | HTTP/1.1 Upgrade |
|
||||||
|
| HTTP/2 | 지원 | 지원 | Extended CONNECT |
|
||||||
|
| HTTP/3 | 지원 | 지원 | Extended CONNECT |
|
||||||
|
|
||||||
|
모든 ingress는 동일한 TLS + yamux 터널을 통해 클라이언트의 로컬 HTTP 서비스로 전달됩니다.
|
||||||
|
All ingress protocols use the same TLS + yamux tunnel to reach the client's local HTTP service.
|
||||||
|
|
||||||
|
### SSE and Extended CONNECT WebSocket
|
||||||
|
|
||||||
|
SSE responses are streamed through the yamux stream and do not use the normal
|
||||||
|
proxy timeout when the request accepts `text/event-stream`. This policy applies
|
||||||
|
to HTTP/1.1, HTTP/2, and HTTP/3 ingress alike; the client is responsible for
|
||||||
|
closing the request context when the SSE connection should end.
|
||||||
|
|
||||||
|
HTTP/2 WebSocket Extended CONNECT is enabled by the Go HTTP/2 implementation
|
||||||
|
with the following process setting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GODEBUG=http2xconnect=1 go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
The Extended CONNECT path translates the HTTP/2 or HTTP/3 WebSocket handshake
|
||||||
|
to the existing local HTTP/1.1 WebSocket connector, then relays the
|
||||||
|
bidirectional stream through yamux.
|
||||||
|
|
||||||
HopGate는 아직 초기 단계의 실험적 프로젝트입니다. API 및 동작은 언제든지 변경될 수 있습니다.
|
HopGate는 아직 초기 단계의 실험적 프로젝트입니다. API 및 동작은 언제든지 변경될 수 있습니다.
|
||||||
HopGate is still experimental; APIs and behavior may change at any time.
|
HopGate is still experimental; APIs and behavior may change at any time.
|
||||||
|
|||||||
+28
-146
@@ -2,35 +2,25 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"flag"
|
"flag"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dalbodeule/hop-gate/internal/config"
|
"github.com/dalbodeule/hop-gate/internal/config"
|
||||||
"github.com/dalbodeule/hop-gate/internal/dtls"
|
|
||||||
"github.com/dalbodeule/hop-gate/internal/logging"
|
"github.com/dalbodeule/hop-gate/internal/logging"
|
||||||
"github.com/dalbodeule/hop-gate/internal/proxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다.
|
|
||||||
// 기본값 "dev" 는 로컬 개발용입니다.
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func getEnvOrPanic(logger logging.Logger, key string) string {
|
func getEnvOrPanic(logger logging.Logger, key string) string {
|
||||||
value, exists := os.LookupEnv(key)
|
value, exists := os.LookupEnv(key)
|
||||||
if !exists || strings.TrimSpace(value) == "" {
|
if !exists || strings.TrimSpace(value) == "" {
|
||||||
logger.Error("missing required environment variable", logging.Fields{
|
logger.Error("missing required environment variable", logging.Fields{"env": key})
|
||||||
"env": key,
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다.
|
|
||||||
func maskAPIKey(key string) string {
|
func maskAPIKey(key string) string {
|
||||||
if len(key) <= 8 {
|
if len(key) <= 8 {
|
||||||
return "***"
|
return "***"
|
||||||
@@ -38,11 +28,10 @@ func maskAPIKey(key string) string {
|
|||||||
return key[:4] + "..." + key[len(key)-4:]
|
return key[:4] + "..." + key[len(key)-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstNonEmpty 는 앞에서부터 처음으로 non-empty 인 문자열을 반환합니다.
|
|
||||||
func firstNonEmpty(values ...string) string {
|
func firstNonEmpty(values ...string) string {
|
||||||
for _, v := range values {
|
for _, value := range values {
|
||||||
if strings.TrimSpace(v) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -50,164 +39,57 @@ func firstNonEmpty(values ...string) string {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := logging.NewStdJSONLogger("client")
|
logger := logging.NewStdJSONLogger("client")
|
||||||
|
|
||||||
// 1. 환경변수(.env 포함)에서 클라이언트 설정 로드
|
|
||||||
// internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다.
|
|
||||||
envCfg, err := config.LoadClientConfigFromEnv()
|
envCfg, err := config.LoadClientConfigFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to load client config from env", logging.Fields{
|
logger.Error("failed to load client config from env", logging.Fields{"error": err.Error()})
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선)
|
|
||||||
serverAddrEnv := getEnvOrPanic(logger, "HOP_CLIENT_SERVER_ADDR")
|
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")
|
apiKeyEnv := getEnvOrPanic(logger, "HOP_CLIENT_API_KEY")
|
||||||
localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET")
|
localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET")
|
||||||
debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG")
|
debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG")
|
||||||
|
|
||||||
// 디버깅 플래그 형식 확인
|
|
||||||
if debugEnv != "true" && debugEnv != "false" {
|
if debugEnv != "true" && debugEnv != "false" {
|
||||||
logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{
|
logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{"value": debugEnv})
|
||||||
"env": "HOP_CLIENT_DEBUG",
|
|
||||||
"value": debugEnv,
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효성 검사 결과를 구조화 로그로 출력
|
serverAddrFlag := flag.String("server-addr", "", "HopGate yamux server address (host:port)")
|
||||||
logger.Info("validated client env vars", logging.Fields{
|
domainFlag := flag.String("domain", "", "registered domain")
|
||||||
"HOP_CLIENT_SERVER_ADDR": serverAddrEnv,
|
apiKeyFlag := flag.String("api-key", "", "client API key for the domain")
|
||||||
"HOP_CLIENT_DOMAIN": clientDomainEnv,
|
localTargetFlag := flag.String("local-target", "", "local HTTP target (host:port)")
|
||||||
"HOP_CLIENT_API_KEY_MASK": maskAPIKey(apiKeyEnv),
|
|
||||||
"HOP_CLIENT_LOCAL_TARGET": localTargetEnv,
|
|
||||||
"HOP_CLIENT_DEBUG": debugEnv,
|
|
||||||
})
|
|
||||||
|
|
||||||
// CLI 인자 정의 (env 보다 우선 적용됨)
|
|
||||||
serverAddrFlag := flag.String("server-addr", "", "DTLS 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")
|
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 2. CLI 인자 우선, env 후순위로 최종 설정 구성
|
|
||||||
finalCfg := &config.ClientConfig{
|
finalCfg := &config.ClientConfig{
|
||||||
ServerAddr: firstNonEmpty(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(envCfg.ServerAddr)),
|
ServerAddr: firstNonEmpty(*serverAddrFlag, envCfg.ServerAddr),
|
||||||
Domain: firstNonEmpty(strings.TrimSpace(*domainFlag), strings.TrimSpace(envCfg.Domain)),
|
Domain: firstNonEmpty(*domainFlag, envCfg.Domain),
|
||||||
ClientAPIKey: firstNonEmpty(strings.TrimSpace(*apiKeyFlag), strings.TrimSpace(envCfg.ClientAPIKey)),
|
ClientAPIKey: firstNonEmpty(*apiKeyFlag, envCfg.ClientAPIKey),
|
||||||
LocalTarget: firstNonEmpty(strings.TrimSpace(*localTargetFlag), strings.TrimSpace(envCfg.LocalTarget)),
|
LocalTarget: firstNonEmpty(*localTargetFlag, envCfg.LocalTarget),
|
||||||
Debug: envCfg.Debug,
|
Debug: envCfg.Debug,
|
||||||
Logging: envCfg.Logging,
|
Logging: envCfg.Logging,
|
||||||
}
|
}
|
||||||
|
if finalCfg.ServerAddr == "" || finalCfg.Domain == "" || finalCfg.ClientAPIKey == "" || finalCfg.LocalTarget == "" {
|
||||||
// 3. 필수 필드 검증
|
logger.Error("client config is incomplete", logging.Fields{
|
||||||
missing := []string{}
|
"server_addr": finalCfg.ServerAddr != "",
|
||||||
if finalCfg.ServerAddr == "" {
|
"domain": finalCfg.Domain != "",
|
||||||
missing = append(missing, "server_addr")
|
"api_key": finalCfg.ClientAPIKey != "",
|
||||||
}
|
"local_target": finalCfg.LocalTarget != "",
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("hop-gate client starting", logging.Fields{
|
logger.Info("hop-gate yamux client starting", logging.Fields{
|
||||||
"stack": "prometheus-loki-grafana",
|
|
||||||
"version": version,
|
"version": version,
|
||||||
"server_addr": finalCfg.ServerAddr,
|
"server_addr": serverAddrEnv,
|
||||||
"domain": finalCfg.Domain,
|
"domain": domainEnv,
|
||||||
"local_target": finalCfg.LocalTarget,
|
"client_api_key_mask": maskAPIKey(apiKeyEnv),
|
||||||
"client_api_key_masked": maskAPIKey(finalCfg.ClientAPIKey),
|
"local_target": localTargetEnv,
|
||||||
"debug": finalCfg.Debug,
|
"debug": finalCfg.Debug,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. DTLS 클라이언트 연결 및 핸드셰이크
|
if err := runYamuxTunnelClient(context.Background(), logger, finalCfg); err != nil {
|
||||||
ctx := context.Background()
|
logger.Error("yamux tunnel client exited with error", logging.Fields{"error": err.Error()})
|
||||||
|
|
||||||
// 디버그 모드에서는 서버 인증서 검증을 스킵(InsecureSkipVerify=true) 하여
|
|
||||||
// self-signed 테스트 인증서도 신뢰하도록 합니다.
|
|
||||||
// 운영 환경에서는 Debug=false 로 두고, 올바른 RootCAs / ServerName 을 갖는 tls.Config 를 사용해야 합니다.
|
|
||||||
var tlsCfg *tls.Config
|
|
||||||
if finalCfg.Debug {
|
|
||||||
tlsCfg = &tls.Config{
|
|
||||||
InsecureSkipVerify: true,
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 운영 모드: 시스템 루트 CA + SNI(ServerName)에 서버 도메인 설정
|
|
||||||
rootCAs, err := x509.SystemCertPool()
|
|
||||||
if err != nil || rootCAs == nil {
|
|
||||||
rootCAs = x509.NewCertPool()
|
|
||||||
}
|
|
||||||
tlsCfg = &tls.Config{
|
|
||||||
RootCAs: rootCAs,
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DTLS 서버 측은 SNI(ServerName)가 HOP_SERVER_DOMAIN(cfg.Domain)과 일치하는지 검사하므로,
|
|
||||||
// 클라이언트 TLS 설정에도 반드시 도메인을 설정해준다.
|
|
||||||
//
|
|
||||||
// finalCfg.ServerAddr 가 "host:port" 형태이므로, SNI 에는 DNS(host) 부분만 넣어야 한다.
|
|
||||||
host := finalCfg.ServerAddr
|
|
||||||
if h, _, err := net.SplitHostPort(finalCfg.ServerAddr); err == nil && strings.TrimSpace(h) != "" {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
tlsCfg.ServerName = host
|
|
||||||
|
|
||||||
client := dtls.NewPionClient(dtls.PionClientConfig{
|
|
||||||
Addr: finalCfg.ServerAddr,
|
|
||||||
TLSConfig: tlsCfg,
|
|
||||||
})
|
|
||||||
|
|
||||||
sess, err := client.Connect()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to establish dtls session", logging.Fields{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer sess.Close()
|
|
||||||
|
|
||||||
hsRes, err := dtls.PerformClientHandshake(ctx, sess, logger, finalCfg.Domain, finalCfg.ClientAPIKey, finalCfg.LocalTarget)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("dtls handshake failed", logging.Fields{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("dtls handshake completed", logging.Fields{
|
|
||||||
"domain": hsRes.Domain,
|
|
||||||
"local_target": finalCfg.LocalTarget,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. DTLS 세션 위에서 서버 요청을 처리하는 클라이언트 프록시 루프 시작
|
|
||||||
clientProxy := proxy.NewClientProxy(logger, finalCfg.LocalTarget)
|
|
||||||
logger.Info("starting client proxy loop", logging.Fields{
|
|
||||||
"local_target": finalCfg.LocalTarget,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := clientProxy.StartLoop(ctx, sess); err != nil {
|
|
||||||
logger.Error("client proxy loop exited with error", logging.Fields{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("client proxy loop exited normally", nil)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+158
-1097
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ services:
|
|||||||
# 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준)
|
# 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준)
|
||||||
- "80:80" # HTTP
|
- "80:80" # HTTP
|
||||||
- "443:443" # HTTPS (TCP)
|
- "443:443" # HTTPS (TCP)
|
||||||
- "443:443/udp" # DTLS (UDP)
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관)
|
# ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관)
|
||||||
|
|||||||
@@ -1,799 +0,0 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// protoc-gen-go v1.36.10
|
|
||||||
// protoc v6.33.1
|
|
||||||
// source: internal/protocol/hopgate_stream.proto
|
|
||||||
|
|
||||||
package protocolpb
|
|
||||||
|
|
||||||
import (
|
|
||||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
|
||||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
|
||||||
reflect "reflect"
|
|
||||||
sync "sync"
|
|
||||||
unsafe "unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Verify that this generated code is sufficiently up-to-date.
|
|
||||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
|
||||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
|
||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
|
||||||
)
|
|
||||||
|
|
||||||
// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다.
|
|
||||||
// HeaderValues wraps multiple header values for a single HTTP header key.
|
|
||||||
type HeaderValues struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *HeaderValues) Reset() {
|
|
||||||
*x = HeaderValues{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *HeaderValues) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*HeaderValues) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *HeaderValues) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead.
|
|
||||||
func (*HeaderValues) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{0}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *HeaderValues) GetValues() []string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Values
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request 는 DTLS 터널 위에서 교환되는 HTTP 요청을 표현합니다.
|
|
||||||
// This mirrors internal/protocol.Request.
|
|
||||||
type Request struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
|
||||||
ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` // optional client identifier
|
|
||||||
ServiceName string `protobuf:"bytes,3,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // logical service name on the client side
|
|
||||||
Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"`
|
|
||||||
Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"`
|
|
||||||
// HTTP header: map of key -> multiple values.
|
|
||||||
Header map[string]*HeaderValues `protobuf:"bytes,6,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
|
||||||
// Raw HTTP body bytes.
|
|
||||||
Body []byte `protobuf:"bytes,7,opt,name=body,proto3" json:"body,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) Reset() {
|
|
||||||
*x = Request{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*Request) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *Request) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use Request.ProtoReflect.Descriptor instead.
|
|
||||||
func (*Request) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{1}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetRequestId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.RequestId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetClientId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.ClientId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetServiceName() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.ServiceName
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetMethod() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Method
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetUrl() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Url
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetHeader() map[string]*HeaderValues {
|
|
||||||
if x != nil {
|
|
||||||
return x.Header
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Request) GetBody() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Body
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response 는 DTLS 터널 위에서 교환되는 HTTP 응답을 표현합니다.
|
|
||||||
// This mirrors internal/protocol.Response.
|
|
||||||
type Response struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
|
||||||
Status int32 `protobuf:"varint,2,opt,name=status,proto3" json:"status,omitempty"`
|
|
||||||
// HTTP header.
|
|
||||||
Header map[string]*HeaderValues `protobuf:"bytes,3,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
|
||||||
// Raw HTTP body bytes.
|
|
||||||
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"`
|
|
||||||
// Optional error description when tunneling fails.
|
|
||||||
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) Reset() {
|
|
||||||
*x = Response{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*Response) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *Response) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use Response.ProtoReflect.Descriptor instead.
|
|
||||||
func (*Response) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{2}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) GetRequestId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.RequestId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) GetStatus() int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.Status
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) GetHeader() map[string]*HeaderValues {
|
|
||||||
if x != nil {
|
|
||||||
return x.Header
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) GetBody() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Body
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Response) GetError() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Error
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamOpen 은 새로운 스트림(HTTP 요청/응답, WebSocket 등)을 여는 메시지입니다.
|
|
||||||
// This represents opening a new stream (HTTP request/response, WebSocket, etc.).
|
|
||||||
type StreamOpen struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID (text form)
|
|
||||||
// Which logical service / local target to use on the client side.
|
|
||||||
ServiceName string `protobuf:"bytes,2,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
|
||||||
TargetAddr string `protobuf:"bytes,3,opt,name=target_addr,json=targetAddr,proto3" json:"target_addr,omitempty"` // e.g. "127.0.0.1:8080"
|
|
||||||
// Initial HTTP-like headers (including Upgrade, etc.).
|
|
||||||
Header map[string]*HeaderValues `protobuf:"bytes,4,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) Reset() {
|
|
||||||
*x = StreamOpen{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StreamOpen) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StreamOpen) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StreamOpen.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StreamOpen) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{3}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) GetId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) GetServiceName() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.ServiceName
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) GetTargetAddr() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.TargetAddr
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamOpen) GetHeader() map[string]*HeaderValues {
|
|
||||||
if x != nil {
|
|
||||||
return x.Header
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamData 는 이미 열린 스트림에 대한 단방향 데이터 프레임입니다.
|
|
||||||
// This is a unidirectional data frame on an already-open stream.
|
|
||||||
type StreamData struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID
|
|
||||||
Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` // per-stream sequence number starting from 0
|
|
||||||
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamData) Reset() {
|
|
||||||
*x = StreamData{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamData) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StreamData) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StreamData) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StreamData.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StreamData) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{4}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamData) GetId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamData) GetSeq() uint64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.Seq
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamData) GetData() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Data
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamAck 는 StreamData 에 대한 ACK/NACK 및 선택적 재전송 힌트를 전달합니다.
|
|
||||||
// This conveys ACK/NACK and optional retransmission hints for StreamData.
|
|
||||||
type StreamAck struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
|
||||||
// Last contiguously received sequence number (starting from 0).
|
|
||||||
AckSeq uint64 `protobuf:"varint,2,opt,name=ack_seq,json=ackSeq,proto3" json:"ack_seq,omitempty"`
|
|
||||||
// Additional missing sequence numbers beyond ack_seq (optional).
|
|
||||||
LostSeqs []uint64 `protobuf:"varint,3,rep,packed,name=lost_seqs,json=lostSeqs,proto3" json:"lost_seqs,omitempty"`
|
|
||||||
// Optional receive window size hint.
|
|
||||||
WindowSize uint32 `protobuf:"varint,4,opt,name=window_size,json=windowSize,proto3" json:"window_size,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) Reset() {
|
|
||||||
*x = StreamAck{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StreamAck) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StreamAck) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StreamAck.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StreamAck) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{5}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) GetId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) GetAckSeq() uint64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.AckSeq
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) GetLostSeqs() []uint64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.LostSeqs
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamAck) GetWindowSize() uint32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.WindowSize
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamClose 는 스트림 종료(정상/에러)를 알립니다.
|
|
||||||
// This indicates normal or error termination of a stream.
|
|
||||||
type StreamClose struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
|
||||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // empty means normal close
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamClose) Reset() {
|
|
||||||
*x = StreamClose{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamClose) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StreamClose) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StreamClose) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StreamClose.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StreamClose) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{6}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamClose) GetId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StreamClose) GetError() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.Error
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다.
|
|
||||||
// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다.
|
|
||||||
// Envelope is the top-level container exchanged over the DTLS session.
|
|
||||||
// Exactly one payload (http_request/http_response/stream_*) is set per message.
|
|
||||||
type Envelope struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
// Types that are valid to be assigned to Payload:
|
|
||||||
//
|
|
||||||
// *Envelope_HttpRequest
|
|
||||||
// *Envelope_HttpResponse
|
|
||||||
// *Envelope_StreamOpen
|
|
||||||
// *Envelope_StreamData
|
|
||||||
// *Envelope_StreamClose
|
|
||||||
// *Envelope_StreamAck
|
|
||||||
Payload isEnvelope_Payload `protobuf_oneof:"payload"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) Reset() {
|
|
||||||
*x = Envelope{}
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*Envelope) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *Envelope) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use Envelope.ProtoReflect.Descriptor instead.
|
|
||||||
func (*Envelope) Descriptor() ([]byte, []int) {
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{7}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetPayload() isEnvelope_Payload {
|
|
||||||
if x != nil {
|
|
||||||
return x.Payload
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetHttpRequest() *Request {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_HttpRequest); ok {
|
|
||||||
return x.HttpRequest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetHttpResponse() *Response {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_HttpResponse); ok {
|
|
||||||
return x.HttpResponse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetStreamOpen() *StreamOpen {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_StreamOpen); ok {
|
|
||||||
return x.StreamOpen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetStreamData() *StreamData {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_StreamData); ok {
|
|
||||||
return x.StreamData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetStreamClose() *StreamClose {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_StreamClose); ok {
|
|
||||||
return x.StreamClose
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *Envelope) GetStreamAck() *StreamAck {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Payload.(*Envelope_StreamAck); ok {
|
|
||||||
return x.StreamAck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type isEnvelope_Payload interface {
|
|
||||||
isEnvelope_Payload()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_HttpRequest struct {
|
|
||||||
HttpRequest *Request `protobuf:"bytes,1,opt,name=http_request,json=httpRequest,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_HttpResponse struct {
|
|
||||||
HttpResponse *Response `protobuf:"bytes,2,opt,name=http_response,json=httpResponse,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_StreamOpen struct {
|
|
||||||
StreamOpen *StreamOpen `protobuf:"bytes,3,opt,name=stream_open,json=streamOpen,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_StreamData struct {
|
|
||||||
StreamData *StreamData `protobuf:"bytes,4,opt,name=stream_data,json=streamData,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_StreamClose struct {
|
|
||||||
StreamClose *StreamClose `protobuf:"bytes,5,opt,name=stream_close,json=streamClose,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Envelope_StreamAck struct {
|
|
||||||
StreamAck *StreamAck `protobuf:"bytes,6,opt,name=stream_ack,json=streamAck,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*Envelope_HttpRequest) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
func (*Envelope_HttpResponse) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
func (*Envelope_StreamOpen) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
func (*Envelope_StreamData) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
func (*Envelope_StreamClose) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
func (*Envelope_StreamAck) isEnvelope_Payload() {}
|
|
||||||
|
|
||||||
var File_internal_protocol_hopgate_stream_proto protoreflect.FileDescriptor
|
|
||||||
|
|
||||||
const file_internal_protocol_hopgate_stream_proto_rawDesc = "" +
|
|
||||||
"\n" +
|
|
||||||
"&internal/protocol/hopgate_stream.proto\x12\x13hopgate.protocol.v1\"&\n" +
|
|
||||||
"\fHeaderValues\x12\x16\n" +
|
|
||||||
"\x06values\x18\x01 \x03(\tR\x06values\"\xc6\x02\n" +
|
|
||||||
"\aRequest\x12\x1d\n" +
|
|
||||||
"\n" +
|
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1b\n" +
|
|
||||||
"\tclient_id\x18\x02 \x01(\tR\bclientId\x12!\n" +
|
|
||||||
"\fservice_name\x18\x03 \x01(\tR\vserviceName\x12\x16\n" +
|
|
||||||
"\x06method\x18\x04 \x01(\tR\x06method\x12\x10\n" +
|
|
||||||
"\x03url\x18\x05 \x01(\tR\x03url\x12@\n" +
|
|
||||||
"\x06header\x18\x06 \x03(\v2(.hopgate.protocol.v1.Request.HeaderEntryR\x06header\x12\x12\n" +
|
|
||||||
"\x04body\x18\a \x01(\fR\x04body\x1a\\\n" +
|
|
||||||
"\vHeaderEntry\x12\x10\n" +
|
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
|
|
||||||
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x8c\x02\n" +
|
|
||||||
"\bResponse\x12\x1d\n" +
|
|
||||||
"\n" +
|
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" +
|
|
||||||
"\x06status\x18\x02 \x01(\x05R\x06status\x12A\n" +
|
|
||||||
"\x06header\x18\x03 \x03(\v2).hopgate.protocol.v1.Response.HeaderEntryR\x06header\x12\x12\n" +
|
|
||||||
"\x04body\x18\x04 \x01(\fR\x04body\x12\x14\n" +
|
|
||||||
"\x05error\x18\x05 \x01(\tR\x05error\x1a\\\n" +
|
|
||||||
"\vHeaderEntry\x12\x10\n" +
|
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
|
|
||||||
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x83\x02\n" +
|
|
||||||
"\n" +
|
|
||||||
"StreamOpen\x12\x0e\n" +
|
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12!\n" +
|
|
||||||
"\fservice_name\x18\x02 \x01(\tR\vserviceName\x12\x1f\n" +
|
|
||||||
"\vtarget_addr\x18\x03 \x01(\tR\n" +
|
|
||||||
"targetAddr\x12C\n" +
|
|
||||||
"\x06header\x18\x04 \x03(\v2+.hopgate.protocol.v1.StreamOpen.HeaderEntryR\x06header\x1a\\\n" +
|
|
||||||
"\vHeaderEntry\x12\x10\n" +
|
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
|
|
||||||
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"B\n" +
|
|
||||||
"\n" +
|
|
||||||
"StreamData\x12\x0e\n" +
|
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" +
|
|
||||||
"\x03seq\x18\x02 \x01(\x04R\x03seq\x12\x12\n" +
|
|
||||||
"\x04data\x18\x03 \x01(\fR\x04data\"r\n" +
|
|
||||||
"\tStreamAck\x12\x0e\n" +
|
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
|
|
||||||
"\aack_seq\x18\x02 \x01(\x04R\x06ackSeq\x12\x1b\n" +
|
|
||||||
"\tlost_seqs\x18\x03 \x03(\x04R\blostSeqs\x12\x1f\n" +
|
|
||||||
"\vwindow_size\x18\x04 \x01(\rR\n" +
|
|
||||||
"windowSize\"3\n" +
|
|
||||||
"\vStreamClose\x12\x0e\n" +
|
|
||||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
|
|
||||||
"\x05error\x18\x02 \x01(\tR\x05error\"\xae\x03\n" +
|
|
||||||
"\bEnvelope\x12A\n" +
|
|
||||||
"\fhttp_request\x18\x01 \x01(\v2\x1c.hopgate.protocol.v1.RequestH\x00R\vhttpRequest\x12D\n" +
|
|
||||||
"\rhttp_response\x18\x02 \x01(\v2\x1d.hopgate.protocol.v1.ResponseH\x00R\fhttpResponse\x12B\n" +
|
|
||||||
"\vstream_open\x18\x03 \x01(\v2\x1f.hopgate.protocol.v1.StreamOpenH\x00R\n" +
|
|
||||||
"streamOpen\x12B\n" +
|
|
||||||
"\vstream_data\x18\x04 \x01(\v2\x1f.hopgate.protocol.v1.StreamDataH\x00R\n" +
|
|
||||||
"streamData\x12E\n" +
|
|
||||||
"\fstream_close\x18\x05 \x01(\v2 .hopgate.protocol.v1.StreamCloseH\x00R\vstreamClose\x12?\n" +
|
|
||||||
"\n" +
|
|
||||||
"stream_ack\x18\x06 \x01(\v2\x1e.hopgate.protocol.v1.StreamAckH\x00R\tstreamAckB\t\n" +
|
|
||||||
"\apayloadB@Z>github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpbb\x06proto3"
|
|
||||||
|
|
||||||
var (
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescData []byte
|
|
||||||
)
|
|
||||||
|
|
||||||
func file_internal_protocol_hopgate_stream_proto_rawDescGZIP() []byte {
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescOnce.Do(func() {
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)))
|
|
||||||
})
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescData
|
|
||||||
}
|
|
||||||
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_goTypes = []any{
|
|
||||||
(*HeaderValues)(nil), // 0: hopgate.protocol.v1.HeaderValues
|
|
||||||
(*Request)(nil), // 1: hopgate.protocol.v1.Request
|
|
||||||
(*Response)(nil), // 2: hopgate.protocol.v1.Response
|
|
||||||
(*StreamOpen)(nil), // 3: hopgate.protocol.v1.StreamOpen
|
|
||||||
(*StreamData)(nil), // 4: hopgate.protocol.v1.StreamData
|
|
||||||
(*StreamAck)(nil), // 5: hopgate.protocol.v1.StreamAck
|
|
||||||
(*StreamClose)(nil), // 6: hopgate.protocol.v1.StreamClose
|
|
||||||
(*Envelope)(nil), // 7: hopgate.protocol.v1.Envelope
|
|
||||||
nil, // 8: hopgate.protocol.v1.Request.HeaderEntry
|
|
||||||
nil, // 9: hopgate.protocol.v1.Response.HeaderEntry
|
|
||||||
nil, // 10: hopgate.protocol.v1.StreamOpen.HeaderEntry
|
|
||||||
}
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_depIdxs = []int32{
|
|
||||||
8, // 0: hopgate.protocol.v1.Request.header:type_name -> hopgate.protocol.v1.Request.HeaderEntry
|
|
||||||
9, // 1: hopgate.protocol.v1.Response.header:type_name -> hopgate.protocol.v1.Response.HeaderEntry
|
|
||||||
10, // 2: hopgate.protocol.v1.StreamOpen.header:type_name -> hopgate.protocol.v1.StreamOpen.HeaderEntry
|
|
||||||
1, // 3: hopgate.protocol.v1.Envelope.http_request:type_name -> hopgate.protocol.v1.Request
|
|
||||||
2, // 4: hopgate.protocol.v1.Envelope.http_response:type_name -> hopgate.protocol.v1.Response
|
|
||||||
3, // 5: hopgate.protocol.v1.Envelope.stream_open:type_name -> hopgate.protocol.v1.StreamOpen
|
|
||||||
4, // 6: hopgate.protocol.v1.Envelope.stream_data:type_name -> hopgate.protocol.v1.StreamData
|
|
||||||
6, // 7: hopgate.protocol.v1.Envelope.stream_close:type_name -> hopgate.protocol.v1.StreamClose
|
|
||||||
5, // 8: hopgate.protocol.v1.Envelope.stream_ack:type_name -> hopgate.protocol.v1.StreamAck
|
|
||||||
0, // 9: hopgate.protocol.v1.Request.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
0, // 10: hopgate.protocol.v1.Response.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
0, // 11: hopgate.protocol.v1.StreamOpen.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
12, // [12:12] is the sub-list for method output_type
|
|
||||||
12, // [12:12] is the sub-list for method input_type
|
|
||||||
12, // [12:12] is the sub-list for extension type_name
|
|
||||||
12, // [12:12] is the sub-list for extension extendee
|
|
||||||
0, // [0:12] is the sub-list for field type_name
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() { file_internal_protocol_hopgate_stream_proto_init() }
|
|
||||||
func file_internal_protocol_hopgate_stream_proto_init() {
|
|
||||||
if File_internal_protocol_hopgate_stream_proto != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file_internal_protocol_hopgate_stream_proto_msgTypes[7].OneofWrappers = []any{
|
|
||||||
(*Envelope_HttpRequest)(nil),
|
|
||||||
(*Envelope_HttpResponse)(nil),
|
|
||||||
(*Envelope_StreamOpen)(nil),
|
|
||||||
(*Envelope_StreamData)(nil),
|
|
||||||
(*Envelope_StreamClose)(nil),
|
|
||||||
(*Envelope_StreamAck)(nil),
|
|
||||||
}
|
|
||||||
type x struct{}
|
|
||||||
out := protoimpl.TypeBuilder{
|
|
||||||
File: protoimpl.DescBuilder{
|
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)),
|
|
||||||
NumEnums: 0,
|
|
||||||
NumMessages: 11,
|
|
||||||
NumExtensions: 0,
|
|
||||||
NumServices: 0,
|
|
||||||
},
|
|
||||||
GoTypes: file_internal_protocol_hopgate_stream_proto_goTypes,
|
|
||||||
DependencyIndexes: file_internal_protocol_hopgate_stream_proto_depIdxs,
|
|
||||||
MessageInfos: file_internal_protocol_hopgate_stream_proto_msgTypes,
|
|
||||||
}.Build()
|
|
||||||
File_internal_protocol_hopgate_stream_proto = out.File
|
|
||||||
file_internal_protocol_hopgate_stream_proto_goTypes = nil
|
|
||||||
file_internal_protocol_hopgate_stream_proto_depIdxs = nil
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
module github.com/dalbodeule/hop-gate
|
module github.com/dalbodeule/hop-gate
|
||||||
|
|
||||||
go 1.25.4
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
entgo.io/ent v0.14.5
|
entgo.io/ent v0.14.5
|
||||||
github.com/go-acme/lego/v4 v4.28.1
|
github.com/go-acme/lego/v4 v4.28.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/hashicorp/yamux v0.1.2
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/pion/dtls/v3 v3.0.7
|
|
||||||
github.com/prometheus/client_golang v1.19.0
|
github.com/prometheus/client_golang v1.19.0
|
||||||
golang.org/x/net v0.47.0
|
github.com/quic-go/quic-go v0.62.0
|
||||||
google.golang.org/protobuf v1.36.10
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -20,25 +20,26 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-openapi/inflect v0.19.0 // indirect
|
github.com/go-openapi/inflect v0.19.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||||
github.com/miekg/dns v1.1.68 // indirect
|
github.com/miekg/dns v1.1.68 // indirect
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
github.com/prometheus/common v0.48.0 // indirect
|
github.com/prometheus/common v0.48.0 // indirect
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/zclconf/go-cty v1.14.4 // indirect
|
github.com/zclconf/go-cty v1.14.4 // indirect
|
||||||
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/tools v0.38.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ
|
|||||||
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q=
|
github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q=
|
||||||
@@ -30,8 +30,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
|
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
|
||||||
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
|
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
|
||||||
|
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||||
|
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -46,14 +50,6 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
|||||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
|
||||||
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
|
||||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
|
||||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
|
||||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||||
@@ -62,31 +58,39 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
|||||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
|
||||||
|
github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
|
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
|
||||||
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||||
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
|
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
|
||||||
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
|
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 817 KiB After Width: | Height: | Size: 2.6 MiB |
+20
-20
@@ -4,7 +4,7 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
|
|||||||
|
|
||||||
=== High-level concept ===
|
=== High-level concept ===
|
||||||
- HopGate is a reverse HTTP gateway.
|
- HopGate is a reverse HTTP gateway.
|
||||||
- A single public server terminates HTTPS and DTLS, and tunnels HTTP traffic to multiple clients.
|
- A single public server terminates HTTPS and exposes a tunnel endpoint (gRPC/HTTP2) to tunnel HTTP traffic to multiple clients.
|
||||||
- Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT).
|
- Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT).
|
||||||
|
|
||||||
=== Main components to draw ===
|
=== Main components to draw ===
|
||||||
@@ -19,8 +19,8 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
|
|||||||
- Terminates TLS using ACME certificates for main and proxy domains.
|
- Terminates TLS using ACME certificates for main and proxy domains.
|
||||||
b. "HTTP Listener (TCP 80)"
|
b. "HTTP Listener (TCP 80)"
|
||||||
- Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS.
|
- Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS.
|
||||||
c. "DTLS Listener (UDP 443 or 8443)"
|
c. "Tunnel Endpoint (gRPC)"
|
||||||
- Terminates DTLS sessions from multiple clients.
|
- gRPC/HTTP2 listener on the same HTTPS port (TCP 443) tunnel streams.
|
||||||
d. "Admin API / Management Plane"
|
d. "Admin API / Management Plane"
|
||||||
- REST API base path: /api/v1/admin
|
- REST API base path: /api/v1/admin
|
||||||
- Endpoints:
|
- Endpoints:
|
||||||
@@ -31,8 +31,9 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
|
|||||||
- Routes incoming HTTP(S) requests to the correct client based on domain and path.
|
- Routes incoming HTTP(S) requests to the correct client based on domain and path.
|
||||||
f. "ACME Certificate Manager"
|
f. "ACME Certificate Manager"
|
||||||
- Automatically issues and renews TLS certificates (Let's Encrypt).
|
- Automatically issues and renews TLS certificates (Let's Encrypt).
|
||||||
g. "DTLS Session Manager"
|
g. "Tunnel Session Manager"
|
||||||
- Manages DTLS connections and per-domain sessions with clients.
|
- Manages tunnel connections and per-domain sessions with clients
|
||||||
|
(gRPC streams).
|
||||||
h. "Metrics & Logging"
|
h. "Metrics & Logging"
|
||||||
- Structured JSON logs shipped to Prometheus / Loki / Grafana stack.
|
- Structured JSON logs shipped to Prometheus / Loki / Grafana stack.
|
||||||
|
|
||||||
@@ -50,35 +51,34 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
|
|||||||
- Draw 2–3 separate client boxes to show that multiple clients can connect.
|
- Draw 2–3 separate client boxes to show that multiple clients can connect.
|
||||||
- Each box titled "HopGate Client".
|
- Each box titled "HopGate Client".
|
||||||
- Inside each client box, show:
|
- Inside each client box, show:
|
||||||
a. "DTLS Client"
|
a. "Tunnel Client"
|
||||||
- Connects to HopGate Server via DTLS.
|
- gRPC client that opens a long-lived bi-directional gRPC stream over HTTPS (HTTP/2).
|
||||||
- Performs handshake with:
|
|
||||||
- domain
|
|
||||||
- client_api_key
|
|
||||||
b. "Client Proxy"
|
b. "Client Proxy"
|
||||||
- Receives HTTP requests from the server over DTLS.
|
- Receives HTTP request frames from the server over the tunnel (gRPC stream).
|
||||||
- Forwards them to local services such as:
|
- Forwards them to local services such as:
|
||||||
- 127.0.0.1:8080 (web)
|
- 127.0.0.1:8080 (web)
|
||||||
- 127.0.0.1:9000 (admin)
|
|
||||||
c. "Local Services"
|
c. "Local Services"
|
||||||
- A small group of boxes representing local HTTP servers.
|
- A small group of boxes representing local HTTP servers.
|
||||||
|
|
||||||
=== Flows to highlight ===
|
=== Flows to highlight ===
|
||||||
1) User HTTP Flow
|
1) User HTTP Flow
|
||||||
- External user -> HTTPS Listener -> Reverse Proxy Core -> DTLS Session Manager -> Specific HopGate Client -> Local Service -> back through same path to the user.
|
- External user -> HTTPS Listener -> Reverse Proxy Core ->
|
||||||
|
gRPC Tunnel Endpoint -> specific HopGate Client (gRPC stream) -> Local Service ->
|
||||||
|
back through same path to the user.
|
||||||
|
|
||||||
2) Admin Flow
|
2) Admin Flow
|
||||||
- Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM:
|
- Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM:
|
||||||
- Register domain + memo -> returns client_api_key.
|
- Register domain + memo -> returns client_api_key.
|
||||||
- Unregister domain + client_api_key.
|
- Unregister domain + client_api_key.
|
||||||
|
|
||||||
3) DTLS Handshake Flow
|
3) Tunnel Handshake / Session Establishment Flow
|
||||||
- From client to server over DTLS:
|
- v1: DTLS Handshake Flow (legacy) - (REMOVED)
|
||||||
- Client sends {domain, client_api_key}.
|
- v2: gRPC Tunnel Establishment Flow:
|
||||||
- Server validates against PostgreSQL Domain table.
|
- From client to server over HTTPS (HTTP/2):
|
||||||
- On success, both sides log:
|
- Client opens a long-lived bi-directional gRPC stream (e.g. OpenTunnel).
|
||||||
- server: which domain is bound to the session.
|
- First frame includes {domain, client_api_key} and client metadata.
|
||||||
- client: success message, bound domain, and local_target (local service address).
|
- Server validates against PostgreSQL Domain table and associates the gRPC stream with that domain.
|
||||||
|
- Subsequent frames carry HTTP request/response metadata and body chunks.
|
||||||
|
|
||||||
=== Visual style ===
|
=== Visual style ===
|
||||||
- Clean flat design, no 3D.
|
- Clean flat design, no 3D.
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import (
|
|||||||
// Manager 는 ACME 기반 인증서 관리를 추상화합니다. (ko)
|
// Manager 는 ACME 기반 인증서 관리를 추상화합니다. (ko)
|
||||||
// Manager abstracts ACME-based certificate management. (en)
|
// Manager abstracts ACME-based certificate management. (en)
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다. (ko)
|
// TLSConfig 는 HTTPS 및 TLS 터널 listener에 주입할 tls.Config 를 반환합니다. (ko)
|
||||||
// TLSConfig returns a tls.Config to be used by HTTPS and DTLS servers. (en)
|
// TLSConfig returns a tls.Config for the HTTPS and TLS tunnel listeners. (en)
|
||||||
TLSConfig() *tls.Config
|
TLSConfig() *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/dalbodeule/hop-gate/ent"
|
"github.com/dalbodeule/hop-gate/ent"
|
||||||
entdomain "github.com/dalbodeule/hop-gate/ent/domain"
|
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/logging"
|
||||||
|
"github.com/dalbodeule/hop-gate/internal/tunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// entDomainValidator 는 ent.Client 를 사용해 Domain 테이블에서
|
// entDomainValidator 는 ent.Client 를 사용해 Domain 테이블에서
|
||||||
@@ -22,7 +22,7 @@ type entDomainValidator struct {
|
|||||||
// NewEntDomainValidator 는 ent 기반 DomainValidator 를 생성합니다.
|
// NewEntDomainValidator 는 ent 기반 DomainValidator 를 생성합니다.
|
||||||
// - domain 파라미터는 "host" 또는 "host:port" 형태 모두 허용하며,
|
// - domain 파라미터는 "host" 또는 "host:port" 형태 모두 허용하며,
|
||||||
// DB 조회 시에는 host 부분만 사용합니다.
|
// DB 조회 시에는 host 부분만 사용합니다.
|
||||||
func NewEntDomainValidator(logger logging.Logger, client *ent.Client) dtls.DomainValidator {
|
func NewEntDomainValidator(logger logging.Logger, client *ent.Client) tunnel.DomainValidator {
|
||||||
return &entDomainValidator{
|
return &entDomainValidator{
|
||||||
logger: logger.With(logging.Fields{"component": "domain_validator"}),
|
logger: logger.With(logging.Fields{"component": "domain_validator"}),
|
||||||
client: client,
|
client: client,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type LokiConfig struct {
|
|||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
HTTPListen string // 예: ":80"
|
HTTPListen string // 예: ":80"
|
||||||
HTTPSListen string // 예: ":443"
|
HTTPSListen string // 예: ":443"
|
||||||
DTLSListen string // 예: ":443"
|
TunnelListen string // TLS + yamux tunnel listener, 예: ":7443"
|
||||||
Domain string // 메인 도메인
|
Domain string // 메인 도메인
|
||||||
ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인
|
ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인
|
||||||
Debug bool // true 이면 디버그 모드 (예: self-signed 인증서 신뢰, 검증 스킵 등)
|
Debug bool // true 이면 디버그 모드 (예: self-signed 인증서 신뢰, 검증 스킵 등)
|
||||||
@@ -40,7 +40,7 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
// ClientConfig 는 클라이언트 프로세스 설정을 담습니다.
|
// ClientConfig 는 클라이언트 프로세스 설정을 담습니다.
|
||||||
// 현재 클라이언트는 다음 4가지 설정만 사용합니다.
|
// 현재 클라이언트는 다음 4가지 설정만 사용합니다.
|
||||||
// - ServerAddr : DTLS 서버 주소 (host:port)
|
// - ServerAddr : 터널 서버 주소 (host:port)
|
||||||
// - Domain : 서버에서 등록된 도메인 (예: api.example.com)
|
// - Domain : 서버에서 등록된 도메인 (예: api.example.com)
|
||||||
// - ClientAPIKey : 도메인에 매핑된 64자 클라이언트 API Key
|
// - ClientAPIKey : 도메인에 매핑된 64자 클라이언트 API Key
|
||||||
// - LocalTarget : 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
|
// - LocalTarget : 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
|
||||||
@@ -48,7 +48,7 @@ type ServerConfig struct {
|
|||||||
// 값은 .env/환경변수와 CLI 인자를 조합해 구성하며,
|
// 값은 .env/환경변수와 CLI 인자를 조합해 구성하며,
|
||||||
// CLI 인자가 우선, env 가 후순위로 적용됩니다.
|
// CLI 인자가 우선, env 가 후순위로 적용됩니다.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
ServerAddr string // DTLS 서버 주소 (host:port)
|
ServerAddr string // 터널 서버 주소 (host:port)
|
||||||
Domain string // 서버에서 등록된 도메인 (예: api.example.com)
|
Domain string // 서버에서 등록된 도메인 (예: api.example.com)
|
||||||
ClientAPIKey string // 도메인에 매핑된 64자 클라이언트 API Key
|
ClientAPIKey string // 도메인에 매핑된 64자 클라이언트 API Key
|
||||||
LocalTarget string // 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
|
LocalTarget string // 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
|
||||||
@@ -224,7 +224,7 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) {
|
|||||||
cfg := &ServerConfig{
|
cfg := &ServerConfig{
|
||||||
HTTPListen: getEnvOrDefault("HOP_SERVER_HTTP_LISTEN", ":80"),
|
HTTPListen: getEnvOrDefault("HOP_SERVER_HTTP_LISTEN", ":80"),
|
||||||
HTTPSListen: getEnvOrDefault("HOP_SERVER_HTTPS_LISTEN", ":443"),
|
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"),
|
Domain: os.Getenv("HOP_SERVER_DOMAIN"),
|
||||||
ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"),
|
ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"),
|
||||||
Debug: getEnvBool("HOP_SERVER_DEBUG", false),
|
Debug: getEnvBool("HOP_SERVER_DEBUG", false),
|
||||||
|
|||||||
@@ -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 등을 사용해 추가합니다.
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
package dtls
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
piondtls "github.com/pion/dtls/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pionSession 은 pion/dtls.Conn 을 감싸 Session 인터페이스를 구현합니다.
|
|
||||||
type pionSession struct {
|
|
||||||
conn *piondtls.Conn
|
|
||||||
id string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *pionSession) Read(b []byte) (int, error) { return s.conn.Read(b) }
|
|
||||||
func (s *pionSession) Write(b []byte) (int, error) { return s.conn.Write(b) }
|
|
||||||
func (s *pionSession) Close() error { return s.conn.Close() }
|
|
||||||
func (s *pionSession) ID() string { return s.id }
|
|
||||||
|
|
||||||
// pionServer 는 pion/dtls 기반 Server 구현입니다.
|
|
||||||
type pionServer struct {
|
|
||||||
listener net.Listener
|
|
||||||
}
|
|
||||||
|
|
||||||
// PionServerConfig 는 DTLS 서버 리스너 구성을 정의합니다.
|
|
||||||
type PionServerConfig struct {
|
|
||||||
// Addr 는 "0.0.0.0:443" 와 같은 UDP 리스닝 주소입니다.
|
|
||||||
Addr string
|
|
||||||
|
|
||||||
// TLSConfig 는 ACME 등을 통해 준비된 tls.Config 입니다.
|
|
||||||
// Certificates, RootCAs, ClientAuth 등의 설정이 여기서 넘어옵니다.
|
|
||||||
// nil 인 경우 기본 빈 tls.Config 가 사용됩니다.
|
|
||||||
TLSConfig *tls.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPionServer 는 pion/dtls 기반 DTLS 서버를 생성합니다.
|
|
||||||
// 내부적으로 udp 리스너를 열고, DTLS 핸드셰이크를 수행할 준비를 합니다.
|
|
||||||
func NewPionServer(cfg PionServerConfig) (Server, error) {
|
|
||||||
if cfg.Addr == "" {
|
|
||||||
return nil, fmt.Errorf("PionServerConfig.Addr is required")
|
|
||||||
}
|
|
||||||
if cfg.TLSConfig == nil {
|
|
||||||
cfg.TLSConfig = &tls.Config{
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
udpAddr, err := net.ResolveUDPAddr("udp", cfg.Addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("resolve udp addr: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tls.Config.GetCertificate (crypto/tls) → pion/dtls.GetCertificate 어댑터
|
|
||||||
var getCert func(*piondtls.ClientHelloInfo) (*tls.Certificate, error)
|
|
||||||
if cfg.TLSConfig.GetCertificate != nil {
|
|
||||||
tlsGetCert := cfg.TLSConfig.GetCertificate
|
|
||||||
getCert = func(chi *piondtls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
if chi == nil {
|
|
||||||
return tlsGetCert(&tls.ClientHelloInfo{})
|
|
||||||
}
|
|
||||||
// ACME 매니저는 주로 SNI(ServerName)에 기반해 인증서를 선택하므로,
|
|
||||||
// 필요한 최소 필드만 복사해서 전달한다.
|
|
||||||
return tlsGetCert(&tls.ClientHelloInfo{
|
|
||||||
ServerName: chi.ServerName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dtlsCfg := &piondtls.Config{
|
|
||||||
// 서버가 사용할 인증서 설정: 정적 Certificates + GetCertificate 어댑터
|
|
||||||
Certificates: cfg.TLSConfig.Certificates,
|
|
||||||
GetCertificate: getCert,
|
|
||||||
InsecureSkipVerify: cfg.TLSConfig.InsecureSkipVerify,
|
|
||||||
ClientAuth: piondtls.ClientAuthType(cfg.TLSConfig.ClientAuth),
|
|
||||||
ClientCAs: cfg.TLSConfig.ClientCAs,
|
|
||||||
RootCAs: cfg.TLSConfig.RootCAs,
|
|
||||||
ServerName: cfg.TLSConfig.ServerName,
|
|
||||||
// 필요 시 ExtendedMasterSecret 등을 추가 설정
|
|
||||||
}
|
|
||||||
l, err := piondtls.Listen("udp", udpAddr, dtlsCfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("dtls listen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pionServer{
|
|
||||||
listener: l,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept 는 새로운 DTLS 연결을 수락하고, Session 으로 래핑합니다.
|
|
||||||
func (s *pionServer) Accept() (Session, error) {
|
|
||||||
conn, err := s.listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dtlsConn, ok := conn.(*piondtls.Conn)
|
|
||||||
if !ok {
|
|
||||||
_ = conn.Close()
|
|
||||||
return nil, fmt.Errorf("accepted connection is not *dtls.Conn")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := ""
|
|
||||||
if ra := dtlsConn.RemoteAddr(); ra != nil {
|
|
||||||
id = ra.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pionSession{
|
|
||||||
conn: dtlsConn,
|
|
||||||
id: id,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 는 DTLS 리스너를 종료합니다.
|
|
||||||
func (s *pionServer) Close() error {
|
|
||||||
return s.listener.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pionClient 는 pion/dtls 기반 Client 구현입니다.
|
|
||||||
type pionClient struct {
|
|
||||||
addr string
|
|
||||||
tlsConfig *tls.Config
|
|
||||||
timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// PionClientConfig 는 DTLS 클라이언트 구성을 정의합니다.
|
|
||||||
type PionClientConfig struct {
|
|
||||||
// Addr 는 서버의 UDP 주소 (예: "example.com:443") 입니다.
|
|
||||||
Addr string
|
|
||||||
|
|
||||||
// TLSConfig 는 서버 인증에 사용할 tls.Config 입니다.
|
|
||||||
// InsecureSkipVerify=true 로 두면 서버 인증을 건너뛰므로 개발/테스트에만 사용해야 합니다.
|
|
||||||
TLSConfig *tls.Config
|
|
||||||
|
|
||||||
// Timeout 은 DTLS 핸드셰이크 타임아웃입니다.
|
|
||||||
// 0 이면 기본값 10초가 사용됩니다.
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPionClient 는 pion/dtls 기반 DTLS 클라이언트를 생성합니다.
|
|
||||||
func NewPionClient(cfg PionClientConfig) Client {
|
|
||||||
if cfg.Timeout == 0 {
|
|
||||||
cfg.Timeout = 10 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.TLSConfig == nil {
|
|
||||||
// 기본값: 인증서 검증을 수행하는 안전한 설정(루트 CA 체인은 시스템 기본값 사용).
|
|
||||||
// 디버그 모드에서 인증서 검증을 스킵하고 싶다면, 호출 측에서
|
|
||||||
// TLSConfig: &tls.Config{InsecureSkipVerify: true} 를 명시적으로 전달해야 합니다.
|
|
||||||
cfg.TLSConfig = &tls.Config{
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &pionClient{
|
|
||||||
addr: cfg.Addr,
|
|
||||||
tlsConfig: cfg.TLSConfig,
|
|
||||||
timeout: cfg.Timeout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect 는 서버와 DTLS 핸드셰이크를 수행하고 Session 을 반환합니다.
|
|
||||||
func (c *pionClient) Connect() (Session, error) {
|
|
||||||
if c.addr == "" {
|
|
||||||
return nil, fmt.Errorf("PionClientConfig.Addr is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
raddr, err := net.ResolveUDPAddr("udp", c.addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("resolve udp addr: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dtlsCfg := &piondtls.Config{
|
|
||||||
// 클라이언트는 서버 인증을 위해 RootCAs/ServerName 만 사용.
|
|
||||||
// (현재는 클라이언트 인증서 사용 계획이 없으므로 GetCertificate 는 전달하지 않는다.)
|
|
||||||
Certificates: c.tlsConfig.Certificates,
|
|
||||||
InsecureSkipVerify: c.tlsConfig.InsecureSkipVerify,
|
|
||||||
RootCAs: c.tlsConfig.RootCAs,
|
|
||||||
ServerName: c.tlsConfig.ServerName,
|
|
||||||
}
|
|
||||||
|
|
||||||
type result struct {
|
|
||||||
conn *piondtls.Conn
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
ch := make(chan result, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
conn, err := piondtls.Dial("udp", raddr, dtlsCfg)
|
|
||||||
ch <- result{conn: conn, err: err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, fmt.Errorf("dtls dial timeout: %w", ctx.Err())
|
|
||||||
case res := <-ch:
|
|
||||||
if res.err != nil {
|
|
||||||
return nil, fmt.Errorf("dtls dial: %w", res.err)
|
|
||||||
}
|
|
||||||
id := ""
|
|
||||||
if ra := res.conn.RemoteAddr(); ra != nil {
|
|
||||||
id = ra.String()
|
|
||||||
}
|
|
||||||
return &pionSession{
|
|
||||||
conn: res.conn,
|
|
||||||
id: id,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 는 클라이언트 단에서 유지하는 리소스가 없으므로 no-op 입니다.
|
|
||||||
func (c *pionClient) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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:]
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
|
/*! 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:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
@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:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -10,8 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// StatusTLSHandshakeFailed is an HTTP-style status code representing
|
// StatusTLSHandshakeFailed is an HTTP-style status code representing
|
||||||
// a TLS/DTLS handshake failure (similar to Cloudflare 525).
|
// a TLS tunnel handshake failure (similar to Cloudflare 525).
|
||||||
// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
|
// TLS 터널 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
|
||||||
const StatusTLSHandshakeFailed = 525
|
const StatusTLSHandshakeFailed = 525
|
||||||
|
|
||||||
// StatusGatewayTimeout is an HTTP-style status code representing
|
// StatusGatewayTimeout is an HTTP-style status code representing
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css -->
|
<!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css -->
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>404 Not Found - HopGate</title>
|
<title>404 Not Found - HopGate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>500 Internal Server Error - HopGate</title>
|
<title>500 Internal Server Error - HopGate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>502 Bad Gateway - HopGate</title>
|
<title>502 Bad Gateway - HopGate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>504 Gateway Timeout - HopGate</title>
|
<title>504 Gateway Timeout - HopGate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<title>525 TLS Handshake Failed - HopGate</title>
|
<title>525 TLS Handshake Failed - HopGate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-xl text-center">
|
<div class="w-full max-w-xl text-center">
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ import (
|
|||||||
// Prometheus 기본 네임스페이스를 사용하며, 메트릭 이름에 hopgate_ 접두어를 붙입니다.
|
// Prometheus 기본 네임스페이스를 사용하며, 메트릭 이름에 hopgate_ 접두어를 붙입니다.
|
||||||
|
|
||||||
var (
|
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 엔드포인트를 통해 들어온 요청 수 (메서드/상태 코드 라벨 포함).
|
// HTTP/Proxy 엔드포인트를 통해 들어온 요청 수 (메서드/상태 코드 라벨 포함).
|
||||||
HTTPRequestsTotal = prometheus.NewCounterVec(
|
HTTPRequestsTotal = prometheus.NewCounterVec(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
@@ -42,7 +33,7 @@ var (
|
|||||||
Name: "hopgate_proxy_errors_total",
|
Name: "hopgate_proxy_errors_total",
|
||||||
Help: "Total number of proxy-related errors, labeled by error type.",
|
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() {
|
func MustRegister() {
|
||||||
prometheus.MustRegister(
|
prometheus.MustRegister(
|
||||||
DTLSHandshakesTotal,
|
|
||||||
HTTPRequestsTotal,
|
HTTPRequestsTotal,
|
||||||
HTTPRequestDurationSeconds,
|
HTTPRequestDurationSeconds,
|
||||||
ProxyErrorsTotal,
|
ProxyErrorsTotal,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package hopgate.protocol.v1;
|
|
||||||
|
|
||||||
option go_package = "github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpb";
|
|
||||||
|
|
||||||
// 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<string, HeaderValues> 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<string, HeaderValues> 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<string, HeaderValues> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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@Z>github.com/dalbodeule/hop-gate/internal/protocol/pb;protocolpbb\x06proto3"
|
|
||||||
|
|
||||||
var (
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescData []byte
|
|
||||||
)
|
|
||||||
|
|
||||||
func file_internal_protocol_hopgate_stream_proto_rawDescGZIP() []byte {
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescOnce.Do(func() {
|
|
||||||
file_internal_protocol_hopgate_stream_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)))
|
|
||||||
})
|
|
||||||
return file_internal_protocol_hopgate_stream_proto_rawDescData
|
|
||||||
}
|
|
||||||
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_goTypes = []any{
|
|
||||||
(*HeaderValues)(nil), // 0: hopgate.protocol.v1.HeaderValues
|
|
||||||
(*Request)(nil), // 1: hopgate.protocol.v1.Request
|
|
||||||
(*Response)(nil), // 2: hopgate.protocol.v1.Response
|
|
||||||
(*StreamOpen)(nil), // 3: hopgate.protocol.v1.StreamOpen
|
|
||||||
(*StreamData)(nil), // 4: hopgate.protocol.v1.StreamData
|
|
||||||
(*StreamAck)(nil), // 5: hopgate.protocol.v1.StreamAck
|
|
||||||
(*StreamClose)(nil), // 6: hopgate.protocol.v1.StreamClose
|
|
||||||
(*Envelope)(nil), // 7: hopgate.protocol.v1.Envelope
|
|
||||||
nil, // 8: hopgate.protocol.v1.Request.HeaderEntry
|
|
||||||
nil, // 9: hopgate.protocol.v1.Response.HeaderEntry
|
|
||||||
nil, // 10: hopgate.protocol.v1.StreamOpen.HeaderEntry
|
|
||||||
}
|
|
||||||
var file_internal_protocol_hopgate_stream_proto_depIdxs = []int32{
|
|
||||||
8, // 0: hopgate.protocol.v1.Request.header:type_name -> hopgate.protocol.v1.Request.HeaderEntry
|
|
||||||
9, // 1: hopgate.protocol.v1.Response.header:type_name -> hopgate.protocol.v1.Response.HeaderEntry
|
|
||||||
10, // 2: hopgate.protocol.v1.StreamOpen.header:type_name -> hopgate.protocol.v1.StreamOpen.HeaderEntry
|
|
||||||
1, // 3: hopgate.protocol.v1.Envelope.http_request:type_name -> hopgate.protocol.v1.Request
|
|
||||||
2, // 4: hopgate.protocol.v1.Envelope.http_response:type_name -> hopgate.protocol.v1.Response
|
|
||||||
3, // 5: hopgate.protocol.v1.Envelope.stream_open:type_name -> hopgate.protocol.v1.StreamOpen
|
|
||||||
4, // 6: hopgate.protocol.v1.Envelope.stream_data:type_name -> hopgate.protocol.v1.StreamData
|
|
||||||
6, // 7: hopgate.protocol.v1.Envelope.stream_close:type_name -> hopgate.protocol.v1.StreamClose
|
|
||||||
5, // 8: hopgate.protocol.v1.Envelope.stream_ack:type_name -> hopgate.protocol.v1.StreamAck
|
|
||||||
0, // 9: hopgate.protocol.v1.Request.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
0, // 10: hopgate.protocol.v1.Response.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
0, // 11: hopgate.protocol.v1.StreamOpen.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
|
|
||||||
12, // [12:12] is the sub-list for method output_type
|
|
||||||
12, // [12:12] is the sub-list for method input_type
|
|
||||||
12, // [12:12] is the sub-list for extension type_name
|
|
||||||
12, // [12:12] is the sub-list for extension extendee
|
|
||||||
0, // [0:12] is the sub-list for field type_name
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() { file_internal_protocol_hopgate_stream_proto_init() }
|
|
||||||
func file_internal_protocol_hopgate_stream_proto_init() {
|
|
||||||
if File_internal_protocol_hopgate_stream_proto != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file_internal_protocol_hopgate_stream_proto_msgTypes[7].OneofWrappers = []any{
|
|
||||||
(*Envelope_HttpRequest)(nil),
|
|
||||||
(*Envelope_HttpResponse)(nil),
|
|
||||||
(*Envelope_StreamOpen)(nil),
|
|
||||||
(*Envelope_StreamData)(nil),
|
|
||||||
(*Envelope_StreamClose)(nil),
|
|
||||||
(*Envelope_StreamAck)(nil),
|
|
||||||
}
|
|
||||||
type x struct{}
|
|
||||||
out := protoimpl.TypeBuilder{
|
|
||||||
File: protoimpl.DescBuilder{
|
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)),
|
|
||||||
NumEnums: 0,
|
|
||||||
NumMessages: 11,
|
|
||||||
NumExtensions: 0,
|
|
||||||
NumServices: 0,
|
|
||||||
},
|
|
||||||
GoTypes: file_internal_protocol_hopgate_stream_proto_goTypes,
|
|
||||||
DependencyIndexes: file_internal_protocol_hopgate_stream_proto_depIdxs,
|
|
||||||
MessageInfos: file_internal_protocol_hopgate_stream_proto_msgTypes,
|
|
||||||
}.Build()
|
|
||||||
File_internal_protocol_hopgate_stream_proto = out.File
|
|
||||||
file_internal_protocol_hopgate_stream_proto_goTypes = nil
|
|
||||||
file_internal_protocol_hopgate_stream_proto_depIdxs = nil
|
|
||||||
}
|
|
||||||
@@ -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"` // 비워두면 정상 종료로 해석
|
|
||||||
}
|
|
||||||
@@ -1,794 +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/dtls"
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) 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())
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Info("client proxy loop stopping due to context cancellation", logging.Fields{
|
|
||||||
"reason": ctx.Err().Error(),
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Error("failed to decode protocol envelope", logging.Fields{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case protocol.MessageTypeStreamOpen:
|
|
||||||
if err := p.handleStreamRequest(ctx, sess, bufferedReader, &env); err != nil {
|
|
||||||
log.Error("failed to handle stream http envelope", logging.Fields{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case protocol.MessageTypeStreamAck:
|
|
||||||
sa := env.StreamAck
|
|
||||||
if sa == nil {
|
|
||||||
log.Error("received stream_ack envelope with nil payload", nil)
|
|
||||||
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 를 기반으로 선택적 재전송 수행
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Info("retransmitted stream_data after stream_ack", logging.Fields{
|
|
||||||
"stream_id": streamID,
|
|
||||||
"seq": seq,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case protocol.MessageTypeStreamData:
|
|
||||||
// StreamData received at top level (not expected, should be consumed by handleStreamRequest)
|
|
||||||
// This can happen if frames arrive out of order or if there's a protocol mismatch
|
|
||||||
streamID := "unknown"
|
|
||||||
if env.StreamData != nil {
|
|
||||||
streamID = string(env.StreamData.ID)
|
|
||||||
}
|
|
||||||
log.Warn("received unexpected stream_data at top level, ignoring", logging.Fields{
|
|
||||||
"stream_id": streamID,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
case protocol.MessageTypeStreamClose:
|
|
||||||
// StreamClose received at top level (not expected, should be consumed by handleStreamRequest)
|
|
||||||
// This can happen if frames arrive out of order or if there's a protocol mismatch
|
|
||||||
streamID := "unknown"
|
|
||||||
if env.StreamClose != nil {
|
|
||||||
streamID = string(env.StreamClose.ID)
|
|
||||||
}
|
|
||||||
log.Warn("received unexpected stream_close at top level, ignoring", logging.Fields{
|
|
||||||
"stream_id": streamID,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
log.Error("received unsupported envelope type from server", logging.Fields{
|
|
||||||
"type": env.Type,
|
|
||||||
})
|
|
||||||
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 dtls.Session, 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 dtls.Session, 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)
|
|
||||||
|
|
||||||
// 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 p.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 = p.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
|
|
||||||
expectedSeq uint64
|
|
||||||
received = make(map[uint64][]byte)
|
|
||||||
lost = make(map[uint64]struct{})
|
|
||||||
)
|
|
||||||
const maxLostReport = 32
|
|
||||||
|
|
||||||
for {
|
|
||||||
var env protocol.Envelope
|
|
||||||
if err := codec.Decode(reader, &env); err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return fmt.Errorf("unexpected EOF while reading stream request body")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("decode stream request frame: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 == expectedSeq:
|
|
||||||
// 기대하던 순서의 프레임: 바로 bodyBuf 에 기록하고, 이후 버퍼된 연속 프레임도 flush.
|
|
||||||
if len(sd.Data) > 0 {
|
|
||||||
if _, err := bodyBuf.Write(sd.Data); err != nil {
|
|
||||||
return fmt.Errorf("buffer stream_data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expectedSeq++
|
|
||||||
for {
|
|
||||||
data, ok := received[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(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 에 추가.
|
|
||||||
if len(sd.Data) > 0 {
|
|
||||||
buf := make([]byte, len(sd.Data))
|
|
||||||
copy(buf, sd.Data)
|
|
||||||
received[sd.Seq] = buf
|
|
||||||
}
|
|
||||||
for seq := expectedSeq; seq < sd.Seq && len(lost) < maxLostReport; seq++ {
|
|
||||||
if _, ok := lost[seq]; !ok {
|
|
||||||
lost[seq] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// sd.Seq < expectedSeq 인 경우: 이미 처리했거나 Ack 로 커버된 프레임 → 무시.
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수신 측 StreamAck 전송:
|
|
||||||
// - AckSeq: 0부터 시작해 연속으로 수신 완료한 마지막 시퀀스 (expectedSeq-1)
|
|
||||||
// - LostSeqs: 현재 윈도우 내에서 누락된 시퀀스 중 상한 개수(maxLostReport)까지만 포함
|
|
||||||
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 := codec.Encode(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": p.LocalTarget,
|
|
||||||
})
|
|
||||||
logReq.Info("received stream_open envelope from server", nil)
|
|
||||||
|
|
||||||
res, err := p.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(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(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(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(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(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(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+174
-150
@@ -9,8 +9,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.1.17",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
@@ -46,9 +46,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -373,68 +373,68 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/cli": {
|
"node_modules/@tailwindcss/cli": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
|
||||||
"integrity": "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg==",
|
"integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parcel/watcher": "^2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@tailwindcss/node": "4.1.17",
|
"@tailwindcss/node": "4.3.3",
|
||||||
"@tailwindcss/oxide": "4.1.17",
|
"@tailwindcss/oxide": "4.3.3",
|
||||||
"enhanced-resolve": "^5.18.3",
|
"enhanced-resolve": "^5.24.1",
|
||||||
"mri": "^1.2.0",
|
"mri": "^1.2.0",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"tailwindcss": "4.1.17"
|
"tailwindcss": "4.3.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tailwindcss": "dist/index.mjs"
|
"tailwindcss": "dist/index.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
|
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"enhanced-resolve": "^5.18.3",
|
"enhanced-resolve": "^5.24.1",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.7.0",
|
||||||
"lightningcss": "1.30.2",
|
"lightningcss": "1.32.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"source-map-js": "^1.2.1",
|
"source-map-js": "^1.2.1",
|
||||||
"tailwindcss": "4.1.17"
|
"tailwindcss": "4.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide": {
|
"node_modules/@tailwindcss/oxide": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||||
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
|
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.17",
|
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.17",
|
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.17",
|
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.17",
|
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
|
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
|
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
|
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
|
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
|
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||||
"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
|
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -445,13 +445,13 @@
|
|||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||||
"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
|
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -462,13 +462,13 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||||
"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
|
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -479,13 +479,13 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||||
"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
|
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -496,13 +496,13 @@
|
|||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||||
"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
|
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -513,81 +513,93 @@
|
|||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||||
"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
|
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||||
"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
|
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||||
"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
|
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||||
"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
|
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||||
"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
|
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@napi-rs/wasm-runtime",
|
"@napi-rs/wasm-runtime",
|
||||||
"@emnapi/core",
|
"@emnapi/core",
|
||||||
@@ -603,21 +615,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "^1.6.0",
|
"@emnapi/core": "^1.11.1",
|
||||||
"@emnapi/runtime": "^1.6.0",
|
"@emnapi/runtime": "^1.11.1",
|
||||||
"@emnapi/wasi-threads": "^1.1.0",
|
"@emnapi/wasi-threads": "^1.2.2",
|
||||||
"@napi-rs/wasm-runtime": "^1.0.7",
|
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||||
"@tybys/wasm-util": "^0.10.1",
|
"@tybys/wasm-util": "^0.10.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
|
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -628,13 +640,13 @@
|
|||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||||
"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
|
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -645,7 +657,7 @@
|
|||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -675,14 +687,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.24.5",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.2.0"
|
"tapable": "^2.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
@@ -742,9 +754,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -752,9 +764,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -768,23 +780,23 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"lightningcss-android-arm64": "1.30.2",
|
"lightningcss-android-arm64": "1.32.0",
|
||||||
"lightningcss-darwin-arm64": "1.30.2",
|
"lightningcss-darwin-arm64": "1.32.0",
|
||||||
"lightningcss-darwin-x64": "1.30.2",
|
"lightningcss-darwin-x64": "1.32.0",
|
||||||
"lightningcss-freebsd-x64": "1.30.2",
|
"lightningcss-freebsd-x64": "1.32.0",
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
"lightningcss-linux-x64-musl": "1.30.2",
|
"lightningcss-linux-x64-musl": "1.32.0",
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-android-arm64": {
|
"node_modules/lightningcss-android-arm64": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -803,9 +815,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -824,9 +836,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -845,9 +857,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -866,9 +878,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -887,13 +899,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -908,13 +923,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -929,13 +947,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -950,13 +971,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -971,9 +995,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -992,9 +1016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
"version": "1.30.2",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1094,16 +1118,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.17",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+2
-2
@@ -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"
|
"build:errors-css": "tailwindcss -c ./tools/tailwind/tailwind.config.cjs -i ./tools/tailwind/input.css -o ./internal/errorpages/assets/errors.css --minify"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.1.17",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-593
@@ -1,593 +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/<status>.html` (env 미설정 시 `./errors/<status>.html`)
|
|
||||||
- 2) `internal/errorpages/templates/<status>.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 / HTTP Tunneling
|
|
||||||
|
|
||||||
- [ ] 서버 측 Proxy 구현 확장: [`internal/proxy/server.go`](internal/proxy/server.go)
|
|
||||||
- 현재 `ServerProxy` / `Router` 인터페이스와 `NewHTTPServer` 만 정의되어 있고,
|
|
||||||
실제 HTTP/HTTPS 리스너와 DTLS 세션 매핑 로직은 [`cmd/server/main.go`](cmd/server/main.go) 의
|
|
||||||
`newHTTPHandler` / `dtlsSessionWrapper.ForwardHTTP` 안에 위치합니다.
|
|
||||||
- Proxy 코어 로직을 proxy 레이어로 이동하는 리팩터링은 아직 진행되지 않았습니다. (3.6 항목과 연동)
|
|
||||||
|
|
||||||
- [x] 클라이언트 측 Proxy 구현 확장: [`internal/proxy/client.go`](internal/proxy/client.go)
|
|
||||||
- DTLS 세션에서 `protocol.Request` 수신 → 로컬 HTTP 호출 → `protocol.Response` 전송 루프 구현.
|
|
||||||
- timeout/취소/에러 처리.
|
|
||||||
|
|
||||||
- [x] 서버 main 에 Proxy wiring 추가: [`cmd/server/main.go`](cmd/server/main.go)
|
|
||||||
- DTLS handshake 완료된 세션을 Proxy 라우팅 테이블에 등록.
|
|
||||||
- HTTPS 서버와 Proxy 핸들러 연결.
|
|
||||||
|
|
||||||
- [x] 클라이언트 main 에 Proxy loop wiring 추가: [`cmd/client/main.go`](cmd/client/main.go)
|
|
||||||
- handshake 성공 후 `proxy.ClientProxy.StartLoop` 실행.
|
|
||||||
|
|
||||||
#### 3.3A Stream-based DTLS Tunneling / 스트림 기반 DTLS 터널링
|
|
||||||
|
|
||||||
초기 HTTP 터널링 설계는 **단일 JSON Envelope + 단일 DTLS 쓰기** 방식(요청/응답 바디 전체를 한 번에 전송)이었고,
|
|
||||||
대용량 응답 바디에서 UDP MTU 한계로 인한 `sendto: message too long` 문제가 발생할 수 있었습니다.
|
|
||||||
이 한계를 제거하기 위해, 현재 코드는 DTLS 위 애플리케이션 프로토콜을 **스트림/프레임 기반**으로 재설계하여 `StreamOpen` / `StreamData` / `StreamClose` 를 사용합니다.
|
|
||||||
The initial tunneling model used a **single JSON envelope + single DTLS write per HTTP message**, which could hit UDP MTU limits (`sendto: message too long`) for large bodies.
|
|
||||||
The current implementation uses a **stream/frame-based** protocol over DTLS (`StreamOpen` / `StreamData` / `StreamClose`), and this section documents its constraints and further improvements (e.g. ARQ).
|
|
||||||
|
|
||||||
고려해야 할 제약 / Constraints:
|
|
||||||
|
|
||||||
- 전송 계층은 DTLS(pion/dtls)를 유지합니다.
|
|
||||||
The transport layer must remain DTLS (pion/dtls).
|
|
||||||
- JSON 기반 단일 Envelope 모델에서 벗어나, HTTP 바디를 안전한 크기의 chunk 로 나누어 전송해야 합니다.
|
|
||||||
We must move away from the single-envelope JSON model and chunk HTTP bodies under a safe MTU.
|
|
||||||
- UDP 특성상 일부 프레임 손실/오염에 대비해, **해당 chunk 만 재전송 요청할 수 있는 ARQ 메커니즘**이 필요합니다.
|
|
||||||
Given UDP characteristics, we need an application-level ARQ so that **only lost/corrupted chunks are retransmitted**.
|
|
||||||
|
|
||||||
아래 단계들은 `feature/udp-stream` 브랜치에서 구현할 구체적인 작업 항목입니다.
|
|
||||||
The following tasks describe concrete work items to be implemented on the `feature/udp-stream` branch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3A.1 스트림 프레이밍 프로토콜 설계 (JSON 1단계)
|
|
||||||
##### 3.3A.1 Stream framing protocol (JSON, phase 1)
|
|
||||||
|
|
||||||
- [x] 스트림 프레임 타입 정리 및 확장: [`internal/protocol/protocol.go`](internal/protocol/protocol.go:35)
|
|
||||||
- 이미 정의된 스트림 관련 타입을 1단계에서 적극 활용합니다.
|
|
||||||
Reuse the already defined stream-related types in phase 1:
|
|
||||||
- `MessageTypeStreamOpen`, `MessageTypeStreamData`, `MessageTypeStreamClose`
|
|
||||||
- [`Envelope`](internal/protocol/protocol.go:52), [`StreamOpen`](internal/protocol/protocol.go:69), [`StreamData`](internal/protocol/protocol.go:80), [`StreamClose`](internal/protocol/protocol.go:86)
|
|
||||||
- `StreamData` 에 per-stream 시퀀스 번호를 추가합니다.
|
|
||||||
Add a per-stream sequence number to `StreamData`:
|
|
||||||
- 예시 / Example:
|
|
||||||
```go
|
|
||||||
type StreamData struct {
|
|
||||||
ID StreamID `json:"id"`
|
|
||||||
Seq uint64 `json:"seq"` // 0부터 시작하는 per-stream sequence
|
|
||||||
Data []byte `json:"data"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] 스트림 ACK / 재전송 제어 메시지 추가: [`internal/protocol/protocol.go`](internal/protocol/protocol.go:52)
|
|
||||||
- 선택적 재전송(Selective Retransmission)을 위해 `StreamAck` 메시지와 `MessageTypeStreamAck` 를 추가합니다.
|
|
||||||
Add `StreamAck` message and `MessageTypeStreamAck` for selective retransmission:
|
|
||||||
```go
|
|
||||||
const (
|
|
||||||
MessageTypeStreamAck MessageType = "stream_ack"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StreamAck struct {
|
|
||||||
ID StreamID `json:"id"` // 대상 스트림 / target stream
|
|
||||||
AckSeq uint64 `json:"ack_seq"` // 연속으로 수신 완료한 마지막 Seq / last contiguous sequence
|
|
||||||
LostSeqs []uint64 `json:"lost_seqs"` // 누락된 시퀀스 목록(선택) / optional list of missing seqs
|
|
||||||
WindowSize uint32 `json:"window_size"` // 선택: 허용 in-flight 프레임 수 / optional receive window
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [`Envelope`](internal/protocol/protocol.go:52)에 `StreamAck *StreamAck` 필드를 추가합니다.
|
|
||||||
Extend `Envelope` with a `StreamAck *StreamAck` field.
|
|
||||||
|
|
||||||
- [x] MTU-safe chunk 크기 정의
|
|
||||||
- DTLS/UDP 헤더 및 Protobuf/length-prefix 오버헤드를 고려해 안전한 payload 크기(4KiB)를 상수로 정의합니다.
|
|
||||||
Define a safe payload size constant (4KiB) considering DTLS/UDP headers and Protobuf/length-prefix framing.
|
|
||||||
- 이 값은 [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32) 의 `StreamChunkSize` 로 정의되었습니다.
|
|
||||||
Implemented as `StreamChunkSize` in [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32).
|
|
||||||
- 이후 HTTP 바디 스트림 터널링 구현 시, 모든 `StreamData.Data` 는 이 크기 이하 chunk 로 잘라 전송해야 합니다.
|
|
||||||
In the stream tunneling implementation, every `StreamData.Data` must be sliced into chunks no larger than this size.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3A.2 애플리케이션 레벨 ARQ 설계 (Selective Retransmission)
|
|
||||||
##### 3.3A.2 Application-level ARQ (Selective Retransmission)
|
|
||||||
|
|
||||||
- [x] 수신 측 ARQ 상태 관리 구현
|
|
||||||
- 스트림별로 `expectedSeq`, out-of-order chunk 버퍼(`received`), 누락 시퀀스 집합(`lost`)을 유지하면서,
|
|
||||||
in-order / out-of-order 프레임을 구분해 HTTP 바디 버퍼에 순서대로 쌓습니다.
|
|
||||||
- For each stream, maintain `expectedSeq`, an out-of-order buffer (`received`), and a lost-sequence set (`lost`),
|
|
||||||
delivering in-order frames directly to the HTTP body buffer while buffering/reordering out-of-order ones.
|
|
||||||
|
|
||||||
- [x] 수신 측 StreamAck 전송 정책 구현
|
|
||||||
- 각 `StreamData` 수신 시점에 `AckSeq = expectedSeq - 1` 과 현재 윈도우에서 누락된 시퀀스 일부(`LostSeqs`, 상한 개수 적용)를 포함한
|
|
||||||
`StreamAck{AckSeq, LostSeqs}` 를 전송해 선택적 재전송을 유도합니다.
|
|
||||||
- On every `StreamData` frame, send `StreamAck{AckSeq, LostSeqs}` where `AckSeq = expectedSeq - 1` and `LostSeqs`
|
|
||||||
contains a bounded set (up to a fixed limit) of missing sequence numbers in the current receive window.
|
|
||||||
|
|
||||||
- [x] 송신 측 재전송 로직 구현 (StreamAck 기반)
|
|
||||||
- 응답 스트림 송신 측에서 스트림별 `streamSender` 를 두고, `outstanding[seq] = payload` 로 아직 Ack 되지 않은 프레임을 추적합니다.
|
|
||||||
- `StreamAck{AckSeq, LostSeqs}` 수신 시:
|
|
||||||
- `seq <= AckSeq` 인 항목은 모두 제거하고,
|
|
||||||
- `LostSeqs` 에 포함된 시퀀스에 대해서만 `StreamData{ID, Seq, Data}` 를 재전송합니다.
|
|
||||||
- A per-stream `streamSender` tracks `outstanding[seq] = payload` for unacknowledged frames. Upon receiving
|
|
||||||
`StreamAck{AckSeq, LostSeqs}`, it deletes all `seq <= AckSeq` and retransmits only frames whose sequence
|
|
||||||
numbers appear in `LostSeqs`.
|
|
||||||
|
|
||||||
> Note: 현재 구현은 StreamAck 기반 **선택적 재전송(Selective Retransmission)** 까지 포함하며,
|
|
||||||
> 별도의 RTO(재전송 타이머) 기반 백그라운드 재전송 루프는 향후 확장 여지로 남겨둔 상태입니다.
|
|
||||||
> Note: The current implementation covers StreamAck-based **selective retransmission**; a separate RTO-based
|
|
||||||
> background retransmission loop is left as a potential future enhancement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3A.3 HTTP ↔ 스트림 매핑 (서버/클라이언트)
|
|
||||||
##### 3.3A.3 HTTP ↔ stream mapping (server/client)
|
|
||||||
|
|
||||||
- [x] 서버 → 클라이언트 요청 스트림: [`cmd/server/main.go`](cmd/server/main.go:200)
|
|
||||||
- `ForwardHTTP` 는 스트림 기반 HTTP 요청/응답을 처리하도록 구현되어 있으며, 동작은 다음과 같습니다.
|
|
||||||
`ForwardHTTP` is implemented in stream mode and behaves as follows:
|
|
||||||
- HTTP 요청 수신 시:
|
|
||||||
- 새로운 `StreamID` 를 발급합니다 (세션별 증가).
|
|
||||||
Generate a new `StreamID` per incoming HTTP request on the DTLS session.
|
|
||||||
- `StreamOpen` 전송:
|
|
||||||
- 요청 메서드/URL/헤더를 [`StreamOpen`](internal/protocol/protocol.go:69) 의 `Header` 혹은 pseudo-header 로 encode.
|
|
||||||
Encode method/URL/headers into `StreamOpen.Header` or a pseudo-header scheme.
|
|
||||||
- 요청 바디를 읽으면서 `StreamData{ID, Seq, Data}` 를 지속적으로 전송합니다.
|
|
||||||
Read the HTTP request body and send it as a sequence of `StreamData` frames.
|
|
||||||
- 바디 종료 시 `StreamClose{ID, Error:""}` 를 전송합니다.
|
|
||||||
When the body ends, send `StreamClose{ID, Error:""}`.
|
|
||||||
- 응답 수신:
|
|
||||||
- 클라이언트에서 오는 역방향 `StreamOpen` 으로 HTTP status/header 를 수신하고,
|
|
||||||
이를 `http.ResponseWriter` 에 반영합니다.
|
|
||||||
Receive response status/headers via reverse-direction `StreamOpen` and map them to `http.ResponseWriter`.
|
|
||||||
- 연속되는 `StreamData` 를 수신할 때마다 `http.ResponseWriter.Write` 로 chunk 를 바로 전송합니다.
|
|
||||||
For each `StreamData`, write the chunk directly to the HTTP response.
|
|
||||||
- `StreamClose` 수신 시 응답 종료 및 스트림 자원 정리.
|
|
||||||
On `StreamClose`, finish the response and clean up per-stream state.
|
|
||||||
|
|
||||||
- [x] 클라이언트에서의 요청 처리 스트림: [`internal/proxy/client.go`](internal/proxy/client.go:200)
|
|
||||||
- 서버로부터 들어오는 `StreamOpen{ID, ...}` 을 수신하면,
|
|
||||||
새로운 goroutine 을 띄워 해당 ID에 대한 로컬 HTTP 요청을 수행합니다.
|
|
||||||
On receiving `StreamOpen{ID, ...}` from the server, spawn a goroutine to handle the local HTTP request for that stream ID.
|
|
||||||
- 스트림별로 `io.Pipe` 또는 채널 기반 바디 리더를 준비하고,
|
|
||||||
`StreamData` 프레임을 수신할 때마다 이 파이프에 쓰도록 합니다.
|
|
||||||
Prepare an `io.Pipe` (or channel-backed reader) per stream and write incoming `StreamData` chunks into it.
|
|
||||||
- 로컬 HTTP 클라이언트 응답은 반대로:
|
|
||||||
For the local HTTP client response:
|
|
||||||
- 응답 status/header → `StreamOpen` (client → server)
|
|
||||||
- 응답 바디 → 여러 개의 `StreamData`
|
|
||||||
- 종료 시점에 `StreamClose` 전송
|
|
||||||
Send `StreamOpen` (status/headers), then a sequence of `StreamData`, followed by `StreamClose` when done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3A.4 JSON → 바이너리 직렬화로의 잠재적 전환 (2단계)
|
|
||||||
##### 3.3A.4 JSON → binary serialization (potential phase 2)
|
|
||||||
|
|
||||||
- [x] JSON 기반 스트림 프로토콜의 1단계 구현/안정화 이후, 직렬화 포맷 재검토 및 Protobuf 전환
|
|
||||||
- 현재는 JSON 대신 Protobuf length-prefix `Envelope` 포맷을 기본으로 사용합니다.
|
|
||||||
The runtime now uses a Protobuf-based, length-prefixed `Envelope` format instead of JSON.
|
|
||||||
- HTTP/스트림 payload 는 여전히 MTU-safe 크기(예: 4KiB, `StreamChunkSize`)로 제한되어 있어, 단일 프레임이 과도하게 커지지 않습니다.
|
|
||||||
HTTP/stream payloads remain bounded to an MTU-safe size (e.g. 4KiB via `StreamChunkSize`), so individual frames stay small.
|
|
||||||
- [x] length-prefix 이진 프레임(Protobuf)으로 전환
|
|
||||||
- 동일한 logical model (`StreamOpen` / `StreamData(seq)` / `StreamClose` / `StreamAck`)을 유지한 채,
|
|
||||||
wire-format 을 Protobuf length-prefix binary 프레이밍으로 교체했고, 이는 `protobufCodec` 으로 구현되었습니다.
|
|
||||||
We now keep the same logical model while using Protobuf length-prefixed framing via `protobufCodec`.
|
|
||||||
- [x] 이 전환은 `internal/protocol` 내 직렬화 레이어를 얇은 abstraction 으로 감싸 구현했습니다.
|
|
||||||
- [`internal/protocol/codec.go`](internal/protocol/codec.go:130) 에 `WireCodec` 인터페이스와 Protobuf 기반 `DefaultCodec` 을 도입해,
|
|
||||||
호출자는 `protocol.DefaultCodec` 만 사용하고, JSON codec 은 보조 용도로만 남아 있습니다.
|
|
||||||
In [`internal/protocol/codec.go`](internal/protocol/codec.go:130), the `WireCodec` abstraction and Protobuf-based `DefaultCodec` allow callers to use only `protocol.DefaultCodec` while JSON remains as an auxiliary codec.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3B DTLS Session Multiplexing / 세션 내 다중 HTTP 요청 처리
|
|
||||||
|
|
||||||
현재 구현은 클라이언트 측에서 단일 DTLS 세션 내에 **동시에 하나의 HTTP 요청 스트림만** 처리할 수 있습니다.
|
|
||||||
`ClientProxy.handleStreamRequest` 가 DTLS 세션의 reader 를 직접 소비하기 때문에, 동일 세션에서 두 번째 `StreamOpen` 이 섞여 들어오면 프로토콜 위반으로 간주되고 세션이 끊어집니다.
|
|
||||||
이 섹션은 **클라이언트 측 스트림 demux + per-stream goroutine 구조**를 도입해, 하나의 DTLS 세션 안에서 여러 HTTP 요청을 안전하게 병렬 처리하기 위한 단계입니다.
|
|
||||||
|
|
||||||
Currently, the client can effectively handle **only one HTTP request stream at a time per DTLS session**.
|
|
||||||
Because `ClientProxy.handleStreamRequest` directly consumes the DTLS session reader, an additional `StreamOpen` for a different stream interleaving on the same session is treated as a protocol error and tears down the session.
|
|
||||||
This section introduces a **client-side stream demultiplexer + per-stream goroutines** to safely support multiple concurrent HTTP requests within a single DTLS session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3B.1 클라이언트 측 중앙 readLoop → 스트림 demux 설계
|
|
||||||
##### 3.3B.1 Design client-side central readLoop → per-stream demux
|
|
||||||
|
|
||||||
- [ ] `ClientProxy.StartLoop` 의 역할을 명확히 분리
|
|
||||||
- DTLS 세션에서 `Envelope` 를 연속해서 읽어들이는 **중앙 readLoop** 를 유지하되,
|
|
||||||
- 개별 스트림의 HTTP 처리 로직(현재 `handleStreamRequest` 내부 로직)을 분리해 별도 타입/구조체로 옮길 계획을 문서화합니다.
|
|
||||||
- [ ] 스트림 demux 위한 자료구조 설계
|
|
||||||
- `map[protocol.StreamID]*streamReceiver` 형태의 수신측 스트림 상태 테이블을 정의합니다.
|
|
||||||
- 각 `streamReceiver` 는 자신만의 입력 채널(예: `inCh chan *protocol.Envelope`)을 가져, 중앙 readLoop 로부터 `StreamOpen/StreamData/StreamClose` 를 전달받도록 합니다.
|
|
||||||
- [ ] 중앙 readLoop 에서 스트림별 라우팅 규칙 정의
|
|
||||||
- `Envelope.Type` 에 따라:
|
|
||||||
- `StreamOpen` / `StreamData` / `StreamClose`:
|
|
||||||
- `streamID` 를 추출하고, 해당 `streamReceiver` 의 `inCh` 로 전달.
|
|
||||||
- `StreamOpen` 수신 시에는 아직 없는 경우 `streamReceiver` 를 생성 후 등록.
|
|
||||||
- `StreamAck`:
|
|
||||||
- 송신 측 ARQ(`streamSender`) 용 테이블(이미 구현된 구조)을 찾아 재전송 로직으로 전달.
|
|
||||||
- 이 설계를 통해 중앙 readLoop 는 **DTLS 세션 → 스트림 단위 이벤트 분배**만 담당하도록 제한합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3B.2 streamReceiver 타입 설계 및 HTTP 매핑 리팩터링
|
|
||||||
##### 3.3B.2 Design streamReceiver type and refactor HTTP mapping
|
|
||||||
|
|
||||||
- [ ] `streamReceiver` 타입 정의
|
|
||||||
- 필드 예시:
|
|
||||||
- `id protocol.StreamID`
|
|
||||||
- 수신 ARQ 상태: `expectedSeq`, `received map[uint64][]byte`, `lost map[uint64]struct{}`
|
|
||||||
- 입력 채널: `inCh chan *protocol.Envelope`
|
|
||||||
- DTLS 세션/codec/logging 핸들: `sess dtls.Session`, `codec protocol.WireCodec`, `logger logging.Logger`
|
|
||||||
- 로컬 HTTP 호출 관련: `HTTPClient *http.Client`, `LocalTarget string`
|
|
||||||
- 역할:
|
|
||||||
- 서버에서 온 `StreamOpen`/`StreamData`/`StreamClose` 를 순서대로 처리해 로컬 HTTP 요청을 구성하고,
|
|
||||||
- 로컬 HTTP 응답을 다시 `StreamOpen`/`StreamData`/`StreamClose` 로 역방향 전송합니다.
|
|
||||||
- [ ] 기존 `ClientProxy.handleStreamRequest` 의 로직을 `streamReceiver` 로 이전
|
|
||||||
- 현재 `handleStreamRequest` 안에서 수행하던 작업을 단계적으로 옮깁니다:
|
|
||||||
- `StreamOpen` 의 pseudo-header 에서 HTTP 메서드/URL/헤더를 복원.
|
|
||||||
- 요청 바디 수신용 수신 측 ARQ(`expectedSeq`, `received`, `lost`) 처리.
|
|
||||||
- 로컬 HTTP 요청 생성/실행 및 에러 처리.
|
|
||||||
- 응답을 4KiB `StreamData` chunk 로 전송 + 송신 측 ARQ(`streamSender.register`) 기록.
|
|
||||||
- 이때 **DTLS reader 를 직접 읽던 부분**은 제거하고, 대신 `inCh` 에서 전달된 `Envelope` 만 사용하도록 리팩터링합니다.
|
|
||||||
- [ ] streamReceiver 생명주기 관리
|
|
||||||
- `StreamClose` 수신 시:
|
|
||||||
- 로컬 HTTP 요청 바디 구성 종료.
|
|
||||||
- 로컬 HTTP 요청 실행 및 응답 스트림 전송 완료 후,
|
|
||||||
- `streamReceivers[streamID]` 에서 자신을 제거하고 goroutine 을 종료하는 정책을 명확히 정의합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3B.3 StartLoop 와 streamReceiver 통합
|
|
||||||
##### 3.3B.3 Integrate StartLoop and streamReceiver
|
|
||||||
|
|
||||||
- [ ] `ClientProxy.StartLoop` 을 “중앙 readLoop + demux” 로 단순화
|
|
||||||
- `MessageTypeStreamOpen` 수신 시:
|
|
||||||
- `streamID := env.StreamOpen.ID` 를 기준으로 기존 `streamReceiver` 존재 여부를 검사.
|
|
||||||
- 없으면 새 `streamReceiver` 생성 후, goroutine 을 띄우고 `inCh <- env` 로 첫 메시지 전달.
|
|
||||||
- `MessageTypeStreamData` / `MessageTypeStreamClose` 수신 시:
|
|
||||||
- 해당 `streamReceiver` 의 `inCh` 로 그대로 전달.
|
|
||||||
- `MessageTypeStreamAck` 는 기존처럼 송신 측 `streamSender` 로 라우팅.
|
|
||||||
- [ ] 에러/종료 처리 전략 정리
|
|
||||||
- 개별 `streamReceiver` 에서 발생하는 에러는:
|
|
||||||
- 로컬 HTTP 에러 → 스트림 응답에 5xx/에러 바디로 반영.
|
|
||||||
- 프로토콜 위반(예: 잘못된 순서의 `StreamClose`) → 해당 스트림만 정리하고 세션은 유지하는지 여부를 정의.
|
|
||||||
- DTLS 세션 레벨 에러(EOF, decode 실패 등)는:
|
|
||||||
- 모든 `streamReceiver` 의 `inCh` 를 닫고,
|
|
||||||
- 이후 클라이언트 전체 루프를 종료하는 방향으로 합의합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### 3.3B.4 세션 단위 직렬화 락 제거 및 멀티플렉싱 검증
|
|
||||||
##### 3.3B.4 Remove session-level serialization lock and validate multiplexing
|
|
||||||
|
|
||||||
- [ ] 서버 측 세션 직렬화 락 제거 계획 수립
|
|
||||||
- 현재 서버는 [`dtlsSessionWrapper`](cmd/server/main.go:111)에 `requestMu` 를 두어,
|
|
||||||
- 동일 DTLS 세션에서 동시에 하나의 `ForwardHTTP` 만 수행하도록 직렬화하고 있습니다.
|
|
||||||
- 클라이언트 측 멀티플렉싱이 안정화되면, `requestMu` 를 제거하고
|
|
||||||
- 하나의 세션 안에서 여러 HTTP 요청이 각기 다른 `StreamID` 로 병렬 진행되도록 허용합니다.
|
|
||||||
- [ ] E2E 멀티플렉싱 테스트 시나리오 정의
|
|
||||||
- 하나의 DTLS 세션 위에서:
|
|
||||||
- 동시에 여러 정적 리소스(`/css`, `/js`, `/img`) 요청.
|
|
||||||
- 큰 응답(수 MB 파일)과 작은 응답(API JSON)이 섞여 있는 시나리오.
|
|
||||||
- 기대 동작:
|
|
||||||
- 어떤 요청이 느리더라도, 다른 요청이 세션 내부 큐잉 때문에 과도하게 지연되지 않고 병렬로 완료되는지 확인.
|
|
||||||
- 클라이언트/서버 로그에 프로토콜 위반(`unexpected envelope type ...`) 이 더 이상 발생하지 않는지 확인.
|
|
||||||
- [ ] 관측성/메트릭에 멀티플렉싱 관련 라벨/필드 추가(선택)
|
|
||||||
- 필요 시:
|
|
||||||
- 세션당 동시 활성 스트림 수,
|
|
||||||
- 스트림 수명(요청-응답 왕복 시간),
|
|
||||||
- 세션 내 스트림 에러 수
|
|
||||||
를 관찰할 수 있는 메트릭/로그 필드를 설계합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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 / 안정성 & 구성
|
|
||||||
|
|
||||||
- [ ] 설정 유효성 검사 추가
|
|
||||||
- 필수 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.
|
|
||||||
Reference in New Issue
Block a user