52 Commits
Author SHA1 Message Date
dalbodeule ebd8463c19 docs: align architecture and API docs with Go 1.27 ingress support
- document HTTP/1.1, HTTP/2, and HTTP/3 ingress
- describe SSE streaming and timeout behavior
- document HTTP/2 and HTTP/3 Extended CONNECT
- update yamux stream and deployment architecture
- document Go 1.27 and UDP HTTP/3 requirements
2026-09-04 16:58:14 +09:00
dalbodeule fe1018469d [refactor] adopt yamux-only tunnel transport [BREAK]
- replace gRPC/DTLS tunnel paths with TLS + yamux transport
- remove legacy protocol, protobuf, DTLS, and proxy implementations
- simplify server and client entrypoints around yamux
- update configuration, metrics, API, and architecture documentation
- add bidirectional yamux session tests
- verify with go test ./...
2026-09-04 16:11:18 +09:00
JinU Choi 8e2f1e68cb Merge pull request #21 from dalbodeule/develop
[release] 1.0.0
2025-12-11 19:40:08 +09:00
JinU Choi 983332b3d8 Merge pull request #20 from dalbodeule/feature/grpc-tunneling
[feat] DTLS 기반 HTTP 터널을 gRPC 기반 HTTP/2 터널로 전환
2025-12-11 19:38:50 +09:00
dalbodeule 38f05db0dc [feat](client): add local HTTP proxying for gRPC-based tunnels
- Enhanced gRPC client with logic to forward incoming tunnel streams as HTTP requests to a local target.
- Implemented per-stream state management for matching StreamOpen/StreamData/StreamClose to HTTP requests/responses.
- Added mechanisms to assemble HTTP requests, send them locally, and respond via tunnel streams.
- Introduced a configurable HTTP client with proper headers and connection settings for robust forwarding.
2025-12-11 19:05:26 +09:00
dalbodeule a41bd34179 [feat](server, errorpages): add gRPC-based tunnel session handling and favicon support
- Implemented gRPC-based tunnel sessions for multiplexing HTTP requests via `grpcTunnelSession` with features like `recvLoop`, `send`, and per-stream state management.
- Registered and unregistered tunnels for domains, replacing DTLS-based sessions for improved scalability and maintainability.
- Integrated domain validation checks during gRPC tunnel handshake with configurable validator support.
- Modified static error pages (`400.html`, `404.html`, `502.html`, `504.html`, `500.html`, `525.html`) to include favicon linking, enhancing error page presentation.
2025-12-11 18:49:56 +09:00
dalbodeule e388e5a272 [debug](server): add temporary debug log for gRPC routing inspection
- Added a debug log in `grpcOrHTTPHandler` to output protocol, content type, host, and path information.
2025-12-11 17:10:56 +09:00
dalbodeule d93440f4b3 [chore](docker): remove unused DTLS-related UDP port mapping
- Removed `443/udp` from `EXPOSE` in `Dockerfile.server`.
- Removed UDP port mapping for `443` in `docker-compose.yml`.
2025-12-11 17:00:36 +09:00
dalbodeule 1492a1a82c [feat](protocol): update go_package path and regen related Protobuf types
- Changed `go_package` option in `hopgate_stream.proto` to `internal/protocol/pb;pb`.
- Regenerated `hopgate_stream.pb.go` with updated package path to align with new structure.
- Added `protocol.md` documenting the gRPC-based HTTP tunneling protocol.
2025-12-11 17:00:12 +09:00
dalbodeule 64f730d2df [feat](protocol, client, server): replace DTLS with gRPC for tunnel implementation
- Introduced gRPC-based tunnel design for bi-directional communication, replacing legacy DTLS transport.
- Added `HopGateTunnel` gRPC service with client and server logic for `OpenTunnel` stream handling.
- Updated client to use gRPC tunnel exclusively, including experimental entry point for stream-based HTTP proxying.
- Removed DTLS-specific client, server, and related dependencies (`pion/dtls`).
- Adjusted `cmd/server` to route gRPC and HTTP/HTTPS traffic dynamically on shared ports.
2025-12-11 16:48:17 +09:00
dalbodeule 17839def69 [feat](docs): update ARCHITECTURE.md to reflect gRPC-based tunnel design
- Replaced legacy DTLS transport details with gRPC/HTTP2 tunnel architecture.
- Updated server and client roles to describe gRPC bi-directional stream-based request/response handling.
- Revised internal component descriptions and flow diagrams to align with gRPC-based implementation.
- Marked DTLS sections as deprecated and documented planned removal in future versions.
2025-12-11 16:07:15 +09:00
dalbodeule faea425e57 [feat](client, server): enable concurrent HTTP stream handling per DTLS session
- Removed session-level serialization lock for HTTP requests (`requestMu`) to support concurrent stream processing.
- Introduced centralized `readLoop` and `streamReceiver` design for stream demultiplexing and individual stream handling.
- Updated client to handle multiple `StreamOpen/StreamData/StreamClose` per session concurrently with per-stream ARQ state.
- Enhanced server logic for efficient HTTP mapping and response streaming in a concurrent stream environment.
2025-12-10 13:05:12 +09:00
JinU Choi 9b7369233c Merge pull request #19 from dalbodeule/copilot/fix-dtls-buffer-error
Fix DTLS buffer size, concurrent request handling, and client frame robustness
2025-12-10 01:26:19 +09:00
dalbodeule 661f8b6413 [feat](server): serialize HTTP requests per DTLS session with session-level mutex
- Added `requestMu` mutex in `dtlsSessionWrapper` to serialize HTTP request handling per DTLS session.
- Prevents interleaved HTTP request streams on clients that process one stream at a time.
- Updated `ForwardHTTP` logic to lock and unlock around HTTP request handling for safe serialization.
- Documented behavior and rationale in `progress.md` for future multiplexing enhancements.
2025-12-10 01:25:56 +09:00
dalbodeule 05dfff21f6 [feat](server, protocol): add sender and receiver ARQ for reliable HTTP stream delivery
- Implemented application-level ARQ with selective retransmission for server-to-client streams, leveraging `StreamAck` logic.
- Added sender-side ARQ state in `streamSender` for tracking and resending unacknowledged frames.
- Introduced receiver-side ARQ with `AckSeq` and `LostSeqs` for handling out-of-order and lost frames.
- Enhanced `dtlsSessionWrapper` to support ARQ management and seamless stream-based DTLS tunneling.
2025-12-10 01:12:58 +09:00
copilot-swe-agent[bot]anddalbodeule 446a265fa2 Improve code readability in client stream frame handlers
- Extract stream IDs before logging calls for better readability
- Remove unnecessary anonymous functions
- Address code review feedback

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:46:00 +00:00
copilot-swe-agent[bot]anddalbodeule 56916c75f4 Fix client handling of unexpected stream frames at top level
- Add handling for StreamData and StreamClose at top level in client StartLoop
- Log warning and continue instead of crashing on unexpected frames
- Fixes "unsupported envelope type stream_close" error when frames arrive out of order

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:44:31 +00:00
copilot-swe-agent[bot]anddalbodeule 887c5fcdff Address code review feedback: cleanup unused code and improve channel safety
- Remove unused streamResponse struct
- Add named constant for response channel buffer size
- Add default case to prevent blocking in readLoop select
- Fix potential double-close of channels during cleanup
- Improve comments explaining concurrency patterns

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:24:56 +00:00
copilot-swe-agent[bot]anddalbodeule ff38ef2828 Fix concurrent request handling with stream multiplexing
- Add channel-based multiplexing to handle concurrent HTTP requests
- Implement background readLoop to dispatch responses to correct streams
- Remove mutex bottleneck that was serializing all requests
- Fixes "unexpected stream_data/stream_open for id" errors with concurrent requests

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:22:29 +00:00
copilot-swe-agent[bot]anddalbodeule 1292df33e5 Fix DTLS buffer size issue by wrapping sessions with buffered readers
- Add dtlsReadBufferSize constant (8KB) matching pion/dtls limits
- Wrap DTLS sessions with bufio.Reader in client and server code
- Update tests to use buffered readers for datagram-based connections
- All tests passing successfully

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:07:15 +00:00
copilot-swe-agent[bot] 412b59f420 Initial plan 2025-12-09 13:59:02 +00:00
dalbodeule 1847a264cb [fix](protocol): improve Protobuf decoding with precise payload reading and clarification
- Refactored `Decode` to use `io.ReadFull` for accurate length-prefix and payload reading.
- Simplified logic to avoid mismatched length issues and clarified comments for maintainability.
2025-12-09 20:11:21 +09:00
JinU Choi d4d6615c0e Merge pull request #18 from dalbodeule/copilot/fix-protobuf-length-prefix-framing
Fix DTLS protobuf codec for UDP datagram boundaries
2025-12-09 20:03:32 +09:00
copilot-swe-agent[bot]anddalbodeule a00c001b49 Improve test documentation for mock datagram connection
Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 10:51:44 +00:00
copilot-swe-agent[bot]anddalbodeule 76423627e9 Fix DTLS protobuf codec framing for datagram boundaries
- Modified protobufCodec.Encode() to combine length prefix and protobuf data into a single buffer and write in one call
- Modified protobufCodec.Decode() to read entire datagram in a single Read call
- Added comprehensive tests for datagram-based codec behavior
- Fixes issue #17: proto: cannot parse invalid wire-format data error in DTLS

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 10:49:37 +00:00
copilot-swe-agent[bot] 9a70256d89 Initial plan 2025-12-09 10:44:22 +00:00
dalbodeule 852a22b8d8 [refactor](build): migrate build_server_image.sh to POSIX sh and improve build options
- Rewrote the script for POSIX compliance (`bash` to `sh`).
- Enhanced environment variable handling for optional arguments (`PLATFORM`, `PUSH`).
- Improved readability and added detailed inline comments for maintainability.
2025-12-09 18:45:22 +09:00
dalbodeule c295d8c20d build_server_image.sh add +x 2025-12-09 18:41:45 +09:00
dalbodeule 1336c540d0 [feat](build): add versioned Docker image build script and version injection
- Introduced `tools/build_server_image.sh` for building versioned server images with support for multi-arch builds.
- Added `VERSION` injection via `-ldflags` in Dockerfile and Go binaries for both server and client.
- Updated workflows and Makefile to ensure consistent version tagging during builds.
2025-12-09 18:41:00 +09:00
dalbodeule 3402616c3e [feat](protocol): regenerate Protobuf Go types from updated hopgate_stream.proto
- Generated `hopgate_stream.pb.go` based on the latest schema for DTLS stream tunneling.
- Added new Protobuf message types, including `Request`, `Response`, `StreamOpen`, `StreamData`, `StreamAck`, `StreamClose`, and `Envelope`.
2025-12-09 18:14:33 +09:00
dalbodeule 715cf6b636 [fix](protocol): improve Protobuf codec buffering for DTLS compatibility
- Updated `Decode` to wrap `io.Reader` in a sufficiently large `bufio.Reader` when handling DTLS sessions, preventing "buffer is too small" errors.
- Enhanced length-prefix reading logic to ensure safe handling of Protobuf envelopes during DTLS stream processing.
- Clarified comments and fixed minor formatting inconsistencies in Protobuf codec documentation.
2025-12-09 17:23:02 +09:00
dalbodeule dfc266f61a [feat](server, client): add runtime validation for critical environment variables
- Introduced `getEnvOrPanic` helper to enforce non-empty required environment variables.
- Added strict validation for server (`HOP_SERVER_*`) and client (`HOP_CLIENT_*`) configurations at startup.
- Updated `.env` loader to prioritize OS env vars over `.env` file values.
- Enhanced structured logging for validated environment variables.
- Improved Makefile with `check-env-server` and `check-env-client` targets for build-time validation.
2025-12-09 00:54:42 +09:00
JinU Choi ab2bc38e32 Merge pull request #16 from dalbodeule/feature/udp-stream
[enchancement] udp stream and protobuf apply
2025-12-09 00:51:36 +09:00
dalbodeule 5c3be0a3bb [feat](client): implement application-level ARQ with selective retransmission
- Added `StreamAck`-based selective retransmission logic for reliable stream delivery.
- Introduced per-stream ARQ states (`expectedSeq`, `lost`, `received`) for out-of-order handling and lost frame tracking.
- Implemented mechanisms to send `StreamAck` with `AckSeq` and `LostSeqs` attributes in response to `StreamData`.
- Enhanced retransmission logic for unacknowledged frames in `streamSender`, ensuring robust recovery for lost data.
- Updated progress notes in `progress.md` to reflect ARQ implementation.
2025-12-09 00:15:03 +09:00
dalbodeule 5e94dd7aa9 [feat](server, client): implement streaming-based HTTP tunnel with DTLS sessions
- Replaced single-envelope HTTP handling with stream-based tunneling (`StreamOpen`, `StreamData`, and `StreamClose`) for HTTP-over-DTLS.
- Added unique StreamID generation for per-session HTTP requests.
- Improved client and server logic for handling chunked body transmissions and reverse stream responses.
- Enhanced pseudo-header handling for HTTP metadata in tunneling.
- Updated error handling for local HTTP failures, ensuring proper stream-based responses.
2025-12-08 23:05:45 +09:00
dalbodeule 798ad75e39 [feat](protocol): enforce 4KiB hard limit on Protobuf body and stream payloads
- Added safeguards to restrict HTTP body and stream payload sizes to 4KiB (`StreamChunkSize`) in the Protobuf codec.
- Updated client logic to apply consistent limits for streaming and non-streaming scenarios.
- Improved error handling with clear messages for oversized payloads.
2025-12-08 22:38:34 +09:00
JinU Choi 65279323ed Merge pull request #15 from dalbodeule/feature/missing-env
[enchancement] Env enchancement.
2025-12-08 22:26:28 +09:00
dalbodeule c5b3c11df0 [refactor](build, Makefile): drop godotenv dependency and fix Korean grammar in env checks
- Removed `godotenv` dependency from `go.mod` as it's no longer used.
- Corrected Korean grammar in Makefile environment variable validation messages.
2025-12-08 22:26:08 +09:00
dalbodeule c81e2c4a81 [docs](README.md): update transport and tunneling details for Protobuf-based messaging
- Updated description of server-client transport to use Protobuf-based, length-prefixed envelopes.
- Revised notes on handling large HTTP bodies and outlined plans for stream/frame-based tunneling.
- Updated `progress.md` with finalized implementation of MTU-safe chunk size constant.
2025-12-08 21:30:45 +09:00
dalbodeule eac39550e2 [feat](protocol): extend Protobuf codec with stream-based message support
- Added support for `StreamOpen`, `StreamData`, `StreamClose`, and `StreamAck` types in the Protobuf codec.
- Defined new pseudo-header constants for HTTP-over-stream tunneling.
- Introduced `StreamChunkSize` constant for MTU-safe payload sizes (4 KiB).
- Updated encoding and decoding logic to handle stream-based types seamlessly.
2025-12-08 21:25:26 +09:00
dalbodeule 302acb640d [docs](README): add detailed documentation for .env and environment variable handling
- Documented the custom `.env` loader behavior, prioritization of OS-level environment variables, and validation stages.
- Explained server and client-specific configuration loading process.
- Added best practices for environment variable usage in development and production environments.
2025-12-08 00:41:58 +09:00
dalbodeule 00b47fda8e [refactor](server, client, config): remove godotenv dependency and enhance env var handling
- Replaced `godotenv` with a custom `.env` loader that respects OS-level environment variables.
- Updated server and client initialization to prioritize OS environment variables over `.env` values.
- Improved environment variable validation and logging with structured logs.
- Applied cleaner error handling and removed redundant `log` package usage.
2025-12-08 00:34:34 +09:00
dalbodeule 01cd524abe [feat](server, client, build): integrate dotenv for environment variable management (by @ryu31847)
- Added `github.com/joho/godotenv` for loading `.env` files in server and client.
- Implemented environment variable validation and logging in both main programs.
- Updated Makefile with `.env` export and validation steps for required variables.
- Simplified error handling in `writeErrorPage` rendering logic.
2025-12-08 00:13:30 +09:00
JinU Choi c643bd2762 Merge pull request #13 from dalbodeule/develop
[fix] dTLS 버퍼 확장 + 프록시 타임아웃 & 호스트별 400/404 처리 (#11, #12)
2025-12-03 01:07:42 +09:00
JinU Choi 4cdcc5542f Merge pull request #10 from dalbodeule/develop
[feat](server): enhance DTLS handshake with DNS/IP-based domain valid…
2025-12-03 00:31:31 +09:00
JinU Choi 7cb5e32096 Merge pull request #9 from dalbodeule/develop
Develop
2025-12-02 23:51:29 +09:00
JinU Choi b3cd168960 Merge pull request #8 from dalbodeule/develop
[fix](errorpages): standardize logo sizing across error templates
2025-12-02 23:10:36 +09:00
JinU Choi 278f411d6b Merge pull request #7 from dalbodeule/develop
[fix](server): enforce static asset handling for `/__hopgate_assets__…
2025-12-02 22:51:57 +09:00
JinU Choi 9161ad4785 Merge pull request #6 from dalbodeule/develop
[chore](build): remove Node.js and Tailwind CSS build steps from serv…
2025-12-02 22:21:10 +09:00
JinU Choi f3e7e2b9c9 Merge pull request #5 from dalbodeule/develop
[feat](errorpages): add custom templates for HTTP errors and assets
2025-12-02 22:07:45 +09:00
JinU Choi ac572148bc Merge pull request #4 from dalbodeule/develop
[feat](server): add ACME standalone-only mode for certificate management
2025-12-02 20:53:19 +09:00
JinU Choi 6633c66da5 Merge pull request #3 from dalbodeule/develop
[feat](server): add ACME standalone-only mode for certificate management
2025-11-28 00:05:36 +09:00
48 changed files with 1746 additions and 4281 deletions
+5 -5
View File
@@ -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
+2
View File
@@ -57,3 +57,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.sha }}
+40 -467
View File
@@ -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
View File
@@ -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 clients `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)
![architecture.jpeg](images/architecture.jpeg)
---
## 4. Next Steps / 다음 단계
- 위 아키텍처를 기반으로 디렉터리와 엔트리 포인트를 생성/정리합니다. (ko)
- Use this architecture to create/organize directories and entrypoints. (en)
- `internal/config` 에 필요한 설정 필드와 `.env` 로더를 확장합니다. (ko)
- Extend `internal/config` with required config fields and `.env` loaders. (en)
- `internal/acme` 에 ACME 클라이언트(certmagic 또는 lego 등)를 연결해 TLS 인증서 발급/갱신을 구현합니다. (ko)
- Wire an ACME client (certmagic, lego, etc.) into `internal/acme` to implement TLS certificate issuance/renewal. (en)
- `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)
+7 -4
View File
@@ -12,12 +12,14 @@
# 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 시에도 별도 인자 없이 빌드 가능합니다.
ARG TARGETOS=linux ARG TARGETOS=linux
ARG TARGETARCH=amd64 ARG TARGETARCH=amd64
# Git 태그/커밋 정보를 main.version 에 주입하기 위한 VERSION 인자 (기본 dev)
ARG VERSION=dev
WORKDIR /src WORKDIR /src
@@ -32,10 +34,11 @@ RUN go mod download
COPY . . COPY . .
# 서버 바이너리 빌드 (멀티 아키텍처: TARGETOS/TARGETARCH 기반) # 서버 바이너리 빌드 (멀티 아키텍처: TARGETOS/TARGETARCH 기반)
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/hop-gate-server ./cmd/server # -ldflags 를 통해 main.version 에 VERSION 값을 주입합니다.
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
@@ -49,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"]
+17 -18
View File
@@ -18,7 +18,13 @@ BIN_DIR := ./bin
SERVER_BIN := $(BIN_DIR)/hop-gate-server SERVER_BIN := $(BIN_DIR)/hop-gate-server
CLIENT_BIN := $(BIN_DIR)/hop-gate-client CLIENT_BIN := $(BIN_DIR)/hop-gate-client
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev) # VERSION 은 현재 커밋의 7글자 SHA 를 사용합니다 (예: 1a2b3c4).
# git 정보가 없으면 dev 로 fallback 합니다.
VERSION ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo dev)
# .env 파일 로드
include .env
export $(shell sed 's/=.*//' .env)
.PHONY: all server client clean docker-server run-server run-client errors-css .PHONY: all server client clean docker-server run-server run-client errors-css
@@ -66,21 +72,14 @@ docker-server:
@echo "Building server Docker image..." @echo "Building server Docker image..."
docker build -f Dockerfile.server -t hop-gate-server:$(VERSION) . docker build -f Dockerfile.server -t hop-gate-server:$(VERSION) .
# --- Protobuf code generation ------------------------------------------------- check-env-server:
# Requires: @if [ -z "$$HOP_SERVER_HTTP_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTP_LISTEN이 설정되지 않았습니다."; exit 1; fi
# - protoc (https://grpc.io/docs/protoc-installation/) @if [ -z "$$HOP_SERVER_HTTPS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTPS_LISTEN가 설정되지 않았습니다."; exit 1; fi
# - protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest) @if [ -z "$$HOP_SERVER_DOMAIN" ]; then echo "필수 환경 변수 HOP_SERVER_DOMAIN가 설정되지 않았습니다."; exit 1; fi
#
# 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."
check-env-client:
@if [ -z "$$HOP_CLIENT_SERVER_ADDR" ]; then echo "필수 환경 변수 HOP_CLIENT_SERVER_ADDR가 설정되지 않았습니다."; exit 1; fi
@if [ -z "$$HOP_CLIENT_DOMAIN" ]; then echo "필수 환경 변수 HOP_CLIENT_DOMAIN가 설정되지 않았습니다."; 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_DEBUG" ]; then echo "필수 환경 변수 HOP_CLIENT_DEBUG가 설정되지 않았습니다."; exit 1; fi
+106 -27
View File
@@ -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 요청/응답을 JSON 기반 메시지로 터널링합니다. - 서버–클라이언트 간 기본 전송은 TLS 위의 TCP와 yamux이며, 하나의 연결에 여러 HTTP logical stream을 multiplex합니다.
Transport between server and clients uses DTLS; currently HTTP requests/responses are tunneled as JSON-based messages. 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 한계 때문에 스트림/프레임 기반 프로토콜로의 전환을 계획하고 있습니다. 자세한 내용은 `progress.md` 의 3.3A 섹션을 참고하세요. (ko) > 참고: yamux logical stream은 HTTP/1.1 wire format을 사용하지만, 요청과 응답 body는 버퍼 전체를 메모리에 올리지 않고 스트리밍됩니다. SSE는 연결이 유지되는 동안 이벤트를 즉시 전달합니다. (ko)
> Note: For very large HTTP bodies, we plan to move to a stream/frame-based protocol over DTLS due to UDP MTU limits. See section 3.3A in `progress.md` for details. (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).
@@ -74,41 +74,84 @@ Build artifacts are created as `./bin/hop-gate-server` and `./bin/hop-gate-clien
--- ---
## 4. DTLS 핸드셰이크 테스트 (Testing DTLS Handshake) ### 3.3 환경변수와 .env 처리 (Environment variables and .env handling)
HopGate는 DTLS 위에서 **도메인 + 클라이언트 API Key** 기반의 애플리케이션 레벨 핸드셰이크를 수행합니다. HopGate 공통 설정을 [`internal/config/config.go`](internal/config/config.go) 에서 로드하며,
HopGate performs an application-level handshake over DTLS using **domain + client API key**. **운영체제 환경변수(OS env)가 `.env` 파일보다 우선**하도록 설계되어 있습니다.
HopGate loads shared configuration from [`internal/config/config.go`](internal/config/config.go) and is designed so that **OS-level environment variables take precedence over `.env`**.
- `.env` 로더: [`loadDotEnvOnce`](internal/config/config.go)
- 현재 작업 디렉터리의 `.env` 파일을 한 번만 읽습니다.
- 이미 OS 환경변수에 설정된 키는 **덮어쓰지 않고 그대로 유지**하고, 비어 있는 키에 대해서만 `.env` 값을 주입합니다.
- `.env` 파일이 존재하지 않으면 조용히 무시합니다 (에러가 아닙니다).
The loader reads the `.env` file once, **does not override existing OS env values**, and only fills missing keys. If `.env` is missing, it is silently ignored.
- 서버 설정 로더 (Server config loader): [`LoadServerConfigFromEnv`](internal/config/config.go)
- `.env` 로더를 먼저 호출한 뒤, `HOP_SERVER_*` 환경변수에서 서버 설정을 구성합니다.
- 실제 실행 시점에는 서버 엔트리포인트 [`cmd/server/main.go`](cmd/server/main.go) 에서 필수 환경변수가 모두 설정되었는지 한 번 더 검증합니다.
It calls the `.env` loader first, then builds server config from `HOP_SERVER_*` env vars, and finally the server entrypoint [`cmd/server/main.go`](cmd/server/main.go) validates required variables.
- 클라이언트 설정 로더 (Client config loader): [`LoadClientConfigFromEnv`](internal/config/config.go)
- `.env` 로더를 동일하게 사용하며, `HOP_CLIENT_*` 환경변수에서 클라이언트 설정을 구성합니다.
- 이후 CLI 인자(예: `--server-addr`, `--domain`)가 있을 경우 env 값보다 우선 적용됩니다.
The same loader is used for `HOP_CLIENT_*` env vars, and CLI flags override env values when provided.
빌드/실행 시 필수 환경변수는 다음 두 단계에서 검증됩니다.
Required environment variables are validated in two stages:
1. **빌드 단계 (Build-time) Makefile 체크 (optional guard)**
- [`Makefile`](Makefile) 에서 `.env``include` 한 뒤, `check-env-server` / `check-env-client` 타깃으로 최소한의 필수 env 를 확인합니다.
- 예) 서버 빌드 시: `make server``errors-css``check-env-server``go build` 순으로 실행됩니다.
The [`Makefile`](Makefile) includes `.env` and uses `check-env-server` / `check-env-client` targets to guard required variables before build.
2. **실행 단계 (Runtime) – 엔트리포인트에서 엄격 검증 (strict runtime validation)**
- 서버: [`cmd/server/main.go`](cmd/server/main.go)
- 헬퍼 `getEnvOrPanic(logger, key)` 를 사용해 `HOP_SERVER_HTTP_LISTEN`, `HOP_SERVER_HTTPS_LISTEN`, `HOP_SERVER_DOMAIN`, `HOP_SERVER_DEBUG` 가 비어 있지 않은지 확인합니다.
- 누락되었거나 공백인 경우, 구조화 에러 로그(JSON)와 함께 프로세스를 종료합니다.
- 클라이언트: [`cmd/client/main.go`](cmd/client/main.go)
- `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, `HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, `HOP_CLIENT_DEBUG` 를 동일한 방식으로 검증합니다.
- 두 경우 모두 `HOP_*_DEBUG` 값은 문자열 `"true"` 또는 `"false"` 만 허용합니다.
Both server and client use a helper (`getEnvOrPanic`) to enforce non-empty required env vars at startup and log structured JSON errors on failure. The debug flags must be the strings `"true"` or `"false"`.
실제 배포 환경에서는 `.env` 보다는 시스템 환경변수(Kubernetes `env`, Docker `-e`, systemd `Environment=` 등)를 사용하는 것을 권장하며,
로컬 개발에서는 `.env.example` 을 복사한 `.env` 파일을 사용해 빠르게 설정을 구성할 수 있습니다.
For production deployments, prefer OS-level env (Kubernetes `env`, Docker `-e`, systemd `Environment=`, etc.), and use a local `.env` (copied from `.env.example`) mainly for development.
## 4. TLS + yamux 터널 설정 (TLS + yamux tunnel configuration)
HopGate는 TLS 연결 위의 yamux control stream에서 **도메인 + 클라이언트 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 요청을 보낼 로컬 서버 주소
@@ -126,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:
@@ -167,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.
+49 -125
View File
@@ -2,20 +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"
) )
// maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다. var version = "dev"
func getEnvOrPanic(logger logging.Logger, key string) string {
value, exists := os.LookupEnv(key)
if !exists || strings.TrimSpace(value) == "" {
logger.Error("missing required environment variable", logging.Fields{"env": key})
os.Exit(1)
}
return value
}
func maskAPIKey(key string) string { func maskAPIKey(key string) string {
if len(key) <= 8 { if len(key) <= 8 {
return "***" return "***"
@@ -23,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 ""
@@ -35,137 +39,57 @@ func firstNonEmpty(values ...string) string {
func main() { func main() {
logger := logging.NewStdJSONLogger("client") logger := logging.NewStdJSONLogger("client")
// 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()
// 1. 환경변수(.env 포함)에서 클라이언트 설정 로드
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. CLI 인자 우선, env 후순위로 최종 설정 구성 serverAddrEnv := getEnvOrPanic(logger, "HOP_CLIENT_SERVER_ADDR")
domainEnv := getEnvOrPanic(logger, "HOP_CLIENT_DOMAIN")
apiKeyEnv := getEnvOrPanic(logger, "HOP_CLIENT_API_KEY")
localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET")
debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG")
if debugEnv != "true" && debugEnv != "false" {
logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{"value": debugEnv})
os.Exit(1)
}
serverAddrFlag := flag.String("server-addr", "", "HopGate yamux server address (host:port)")
domainFlag := flag.String("domain", "", "registered domain")
apiKeyFlag := flag.String("api-key", "", "client API key for the domain")
localTargetFlag := flag.String("local-target", "", "local HTTP target (host:port)")
flag.Parse()
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,
"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)
} }
+177
View File
@@ -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
}
+207 -575
View File
@@ -1,8 +1,8 @@
package main package main
import ( import (
"bufio"
"context" "context"
"crypto/tls"
"fmt" "fmt"
"io" "io"
stdfs "io/fs" stdfs "io/fs"
@@ -16,248 +16,86 @@ import (
"time" "time"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/quic-go/quic-go/http3"
"github.com/dalbodeule/hop-gate/internal/acme" "github.com/dalbodeule/hop-gate/internal/acme"
"github.com/dalbodeule/hop-gate/internal/admin" "github.com/dalbodeule/hop-gate/internal/admin"
"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/errorpages" "github.com/dalbodeule/hop-gate/internal/errorpages"
"github.com/dalbodeule/hop-gate/internal/logging" "github.com/dalbodeule/hop-gate/internal/logging"
"github.com/dalbodeule/hop-gate/internal/observability" "github.com/dalbodeule/hop-gate/internal/observability"
"github.com/dalbodeule/hop-gate/internal/protocol"
"github.com/dalbodeule/hop-gate/internal/store" "github.com/dalbodeule/hop-gate/internal/store"
) )
type dtlsSessionWrapper struct { var version = "dev"
sess dtls.Session
mu sync.Mutex
}
// canonicalizeDomainForDNS 는 DTLS 핸드셰이크에서 전달된 도메인 문자열을 func getEnvOrPanic(logger logging.Logger, key string) string {
// DNS 조회 및 DB 조회에 사용할 수 있는 정규화된 호스트명으로 변환합니다. (ko) value, exists := os.LookupEnv(key)
// canonicalizeDomainForDNS normalizes the domain string from the DTLS handshake if !exists || strings.TrimSpace(value) == "" {
// into a host name suitable for DNS and DB lookups. (en) logger.Error("missing required environment variable", logging.Fields{"env": key})
func canonicalizeDomainForDNS(raw string) string { os.Exit(1)
d := strings.TrimSpace(raw)
if d == "" {
return ""
} }
// "host:port" 형태가 들어온 경우 포트를 제거합니다. (ko) return value
// Strip port if the value is in "host:port" form. (en)
if h, _, err := net.SplitHostPort(d); err == nil && strings.TrimSpace(h) != "" {
d = h
}
return strings.ToLower(d)
}
// domainGateValidator 는 DTLS 핸드셰이크 시 도메인이 EXPECT_IPS(HOP_ACME_EXPECT_IPS)에
// 설정된 IP(IPv4/IPv6)로 해석되는지 검사한 뒤, 내부 DomainValidator 로 위임합니다. (ko)
// domainGateValidator first checks that the domain resolves to one of the
// expected IPs (from HOP_ACME_EXPECT_IPS), then delegates to the inner
// DomainValidator for (domain, client_api_key) validation. (en)
type domainGateValidator struct {
expectedIPs []net.IP
inner dtls.DomainValidator
logger logging.Logger
}
func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error {
d := canonicalizeDomainForDNS(domain)
if d == "" {
return fmt.Errorf("empty domain is not allowed for dtls handshake")
}
// EXPECT_IPS(HOP_ACME_EXPECT_IPS)가 설정된 경우, 도메인이 해당 IP(IPv4/IPv6)들로
// 해석되는지 DNS(A/AAAA) 조회를 통해 검증합니다. (ko)
// If EXPECT_IPS (HOP_ACME_EXPECT_IPS) is configured, ensure that the domain
// resolves (via A/AAAA) to at least one of the expected IPs. (en)
if len(v.expectedIPs) > 0 {
resolver := net.DefaultResolver
if ctx == nil {
ctx = context.Background()
}
ips, err := resolver.LookupIP(ctx, "ip", d)
if err != nil {
if v.logger != nil {
v.logger.Warn("dtls handshake dns resolution failed", logging.Fields{
"domain": d,
"error": err.Error(),
})
}
return fmt.Errorf("dns resolution failed for %s: %w", d, err)
}
match := false
for _, ip := range ips {
for _, expected := range v.expectedIPs {
if ip.Equal(expected) {
match = true
break
}
}
if match {
break
}
}
if !match {
if v.logger != nil {
v.logger.Warn("dtls handshake rejected due to unexpected resolved IPs", logging.Fields{
"domain": d,
"resolved_ips": ips,
"expected_ips": v.expectedIPs,
})
}
return fmt.Errorf("domain %s does not resolve to any expected IPs", d)
}
}
if v.inner != nil {
return v.inner.ValidateDomainAPIKey(ctx, d, clientAPIKey)
}
return nil
}
// parseExpectedIPsFromEnv 는 HOP_ACME_EXPECT_IPS 와 같이 콤마로 구분된 IP 목록
// 환경변수를 파싱해 net.IP 슬라이스로 변환합니다. IPv4/IPv6 모두 지원합니다. (ko)
// parseExpectedIPsFromEnv parses a comma-separated list of IPs from env (e.g. HOP_ACME_EXPECT_IPS)
// into a slice of net.IP, supporting both IPv4 and IPv6 literals. (en)
func parseExpectedIPsFromEnv(logger logging.Logger, envKey string) []net.IP {
raw := strings.TrimSpace(os.Getenv(envKey))
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
var result []net.IP
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
ip := net.ParseIP(p)
if ip == nil {
if logger != nil {
logger.Warn("invalid ip in env, skipping", logging.Fields{
"env": envKey,
"value": p,
})
}
continue
}
result = append(result, ip)
}
if logger != nil {
logger.Info("loaded expected handshake ips from env", logging.Fields{
"env": envKey,
"ips": result,
})
}
return result
}
// ForwardHTTP 는 단일 HTTP 요청을 DTLS 세션으로 포워딩하고 응답을 돌려받습니다.
// ForwardHTTP forwards a single HTTP request over the DTLS session and returns the response.
func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) {
w.mu.Lock()
defer w.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
// 요청 본문 읽기
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
body = b
}
// 간단한 RequestID 생성 (실제 서비스에서는 UUID 등을 사용하는 것이 좋음)
requestID := time.Now().UTC().Format("20060102T150405.000000000")
httpReq := &protocol.Request{
RequestID: requestID,
ClientID: "", // TODO: 클라이언트 식별자 도입 시 채우기
ServiceName: serviceName,
Method: req.Method,
URL: req.URL.String(),
Header: req.Header.Clone(),
Body: body,
}
log := logger.With(logging.Fields{
"component": "http_to_dtls",
"request_id": requestID,
"method": req.Method,
"url": req.URL.String(),
})
log.Info("forwarding http request over dtls", logging.Fields{
"host": req.Host,
"scheme": req.URL.Scheme,
})
// HTTP 요청을 Envelope 로 감싸서 전송합니다.
env := &protocol.Envelope{
Type: protocol.MessageTypeHTTP,
HTTPRequest: httpReq,
}
if err := protocol.DefaultCodec.Encode(w.sess, env); err != nil {
log.Error("failed to encode http envelope", logging.Fields{
"error": err.Error(),
})
return nil, err
}
// 클라이언트로부터 HTTP 응답 Envelope 를 수신합니다.
var respEnv protocol.Envelope
if err := protocol.DefaultCodec.Decode(w.sess, &respEnv); err != nil {
log.Error("failed to decode http envelope", logging.Fields{
"error": err.Error(),
})
return nil, err
}
if respEnv.Type != protocol.MessageTypeHTTP || respEnv.HTTPResponse == nil {
log.Error("received non-http envelope from client", logging.Fields{
"type": respEnv.Type,
})
return nil, fmt.Errorf("unexpected envelope type %q or empty http_response", respEnv.Type)
}
protoResp := respEnv.HTTPResponse
log.Info("received dtls response", logging.Fields{
"status": protoResp.Status,
"error": protoResp.Error,
})
return protoResp, nil
} }
var ( var (
sessionsMu sync.RWMutex tunnelsMu sync.RWMutex
sessionsByDomain = make(map[string]*dtlsSessionWrapper) tunnelsByDomain = make(map[string]forwardTunnel)
) )
// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다. type forwardTunnel interface {
// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다. ForwardHTTP(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error
}
type extendedConnectForwarder interface {
ForwardExtendedConnect(context.Context, logging.Logger, *http.Request, string, http.ResponseWriter) error
}
func registerTunnelForDomain(domain string, sess forwardTunnel, logger logging.Logger) string {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" || sess == nil {
return ""
}
tunnelsMu.Lock()
tunnelsByDomain[d] = sess
tunnelsMu.Unlock()
logger.Info("registered yamux tunnel for domain", logging.Fields{"domain": d})
return d
}
func unregisterTunnelForDomain(domain string, sess forwardTunnel, logger logging.Logger) {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" || sess == nil {
return
}
tunnelsMu.Lock()
if current := tunnelsByDomain[d]; current == sess {
delete(tunnelsByDomain, d)
}
tunnelsMu.Unlock()
logger.Info("unregistered yamux tunnel for domain", logging.Fields{"domain": d})
}
func getTunnelForHost(host string) forwardTunnel {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return nil
}
if name, _, err := net.SplitHostPort(h); err == nil {
h = name
} else if i := strings.LastIndex(h, ":"); i > -1 && !strings.Contains(h[i+1:], "]") {
h = h[:i]
}
tunnelsMu.RLock()
defer tunnelsMu.RUnlock()
return tunnelsByDomain[h]
}
type statusRecorder struct { type statusRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int
} }
func (w *statusRecorder) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
// hopGateOwnedHeaders 는 HopGate 서버가 스스로 관리하는 응답 헤더 목록입니다. (ko)
// hopGateOwnedHeaders lists response headers that are owned by the HopGate server. (en)
var hopGateOwnedHeaders = map[string]struct{}{ var hopGateOwnedHeaders = map[string]struct{}{
"X-HopGate-Server": {}, "X-HopGate-Server": {},
"Strict-Transport-Security": {}, "Strict-Transport-Security": {},
@@ -265,91 +103,35 @@ var hopGateOwnedHeaders = map[string]struct{}{
"Referrer-Policy": {}, "Referrer-Policy": {},
} }
// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/502/504/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko)
// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/502/504/525). (en)
//
// 템플릿 로딩 우선순위: (ko)
// 1. HOP_ERROR_PAGES_DIR/<status>.html (또는 ./errors/<status>.html) (ko)
// 2. go:embed 로 내장된 templates/<status>.html (ko)
//
// Template loading priority: (en)
// 1. HOP_ERROR_PAGES_DIR/<status>.html (or ./errors/<status>.html) (en)
// 2. go:embed'ed templates/<status>.html (en)
func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) { func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) {
// 공통 보안/식별 헤더를 best-effort 로 설정합니다. (ko)
// Configure common security and identity headers (best-effort). (en)
if r != nil { if r != nil {
setSecurityAndIdentityHeaders(w, r) setSecurityAndIdentityHeaders(w, r)
} }
errorpages.Render(w, r, status)
// 4xx / 5xx 대역에 대한 템플릿 매핑 규칙: (ko)
// - 400 series: 400.html 로 렌더링 (단, 404 는 404.html 사용) (ko)
// - 500 series: 500.html 로 렌더링 (단, 502/504/525 는 개별 템플릿 사용) (ko)
//
// Mapping rules for 4xx / 5xx ranges: (en)
// - 400 series: render using 400.html (except 404 uses 404.html). (en)
// - 500 series: render using 500.html (except 502/504/525 which have dedicated templates). (en)
mapped := status
switch {
case status >= 400 && status < 500:
if status != http.StatusBadRequest && status != http.StatusNotFound {
mapped = http.StatusBadRequest
}
case status >= 500 && status < 600:
if status != http.StatusInternalServerError &&
status != http.StatusBadGateway &&
status != errorpages.StatusGatewayTimeout &&
status != errorpages.StatusTLSHandshakeFailed {
mapped = http.StatusInternalServerError
}
}
// Delegates actual HTML rendering to internal/errorpages with mapped status. (en)
// 실제 HTML 렌더링은 매핑된 상태 코드로 internal/errorpages 패키지에 위임합니다. (ko)
errorpages.Render(w, r, mapped)
} }
// setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko)
// setSecurityAndIdentityHeaders configures common security and identity headers for HopGate. (en)
func setSecurityAndIdentityHeaders(w http.ResponseWriter, r *http.Request) { func setSecurityAndIdentityHeaders(w http.ResponseWriter, r *http.Request) {
h := w.Header() h := w.Header()
// HopGate 로 구성된 서버임을 나타내는 식별 헤더 (ko)
// Header to indicate that this server is powered by HopGate. (en)
h.Set("X-HopGate-Server", "hop-gate") h.Set("X-HopGate-Server", "hop-gate")
// 기본 보안 헤더 설정 (ko)
// Basic security headers (best-effort). (en)
h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HTTPS 요청에 대해서만 HSTS 헤더를 추가합니다. (ko)
// Only send HSTS for HTTPS requests. (en)
if r != nil && r.TLS != nil { if r != nil && r.TLS != nil {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
} }
} }
// hostDomainHandler 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 요청을 허용하는 래퍼입니다.
// Host 헤더에서 포트를 제거한 뒤 소문자 비교를 수행합니다.
func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Handler) http.Handler { func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Handler) http.Handler {
allowed := strings.ToLower(strings.TrimSpace(allowedDomain)) allowed := strings.ToLower(strings.TrimSpace(allowedDomain))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if allowed != "" { if allowed != "" {
host := r.Host host := r.Host
if i := strings.Index(host, ":"); i != -1 { if h, _, err := net.SplitHostPort(host); err == nil {
host = host[:i] host = h
} else {
host = strings.Trim(host, "[]")
} }
host = strings.ToLower(strings.TrimSpace(host)) if !strings.EqualFold(strings.TrimSpace(host), allowed) {
if host != allowed { logger.Warn("rejecting request due to mismatched host", logging.Fields{"allowed_domain": allowed, "request_host": host, "path": r.URL.Path})
logger.Warn("rejecting request due to mismatched host", logging.Fields{
"allowed_domain": allowed,
"request_host": host,
"path": r.URL.Path,
})
// 메트릭/관리용 엔드포인트에 대해 호스트가 다르면 404 페이지로 응답하여 노출을 최소화합니다. (ko)
// For metrics/admin endpoints, respond with a 404 page when host mismatches to reduce exposure. (en)
writeErrorPage(w, r, http.StatusNotFound) writeErrorPage(w, r, http.StatusNotFound)
return return
} }
@@ -358,37 +140,37 @@ func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Ha
}) })
} }
func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) { func (w *statusRecorder) WriteHeader(code int) {
d := strings.ToLower(strings.TrimSpace(domain)) if w.status != 0 {
if d == "" {
return return
} }
w := &dtlsSessionWrapper{sess: sess} w.status = code
sessionsMu.Lock() w.ResponseWriter.WriteHeader(code)
sessionsByDomain[d] = w
sessionsMu.Unlock()
logger.Info("registered dtls session for domain", logging.Fields{
"domain": d,
"sid": sess.ID(),
})
} }
func getSessionForHost(host string) *dtlsSessionWrapper { func (w *statusRecorder) Write(p []byte) (int, error) {
// host may contain port (e.g. "example.com:443"); strip port. if w.status == 0 {
h := host w.WriteHeader(http.StatusOK)
if i := strings.Index(h, ":"); i != -1 {
h = h[:i]
} }
h = strings.ToLower(strings.TrimSpace(h)) return w.ResponseWriter.Write(p)
if h == "" {
return nil
}
sessionsMu.RLock()
defer sessionsMu.RUnlock()
return sessionsByDomain[h]
} }
func (w *statusRecorder) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("underlying response writer does not support hijacking")
}
return hijacker.Hijack()
}
func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Handler { func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Handler {
// ACME webroot (for HTTP-01) is read from env; must match HOP_ACME_WEBROOT used by lego. // ACME webroot (for HTTP-01) is read from env; must match HOP_ACME_WEBROOT used by lego.
webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT")) webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT"))
@@ -400,13 +182,13 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
allowedDomain := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_SERVER_DOMAIN"))) allowedDomain := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_SERVER_DOMAIN")))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// NOTE: /__hopgate_assets__/ 경로는 DTLS/백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko) // NOTE: /__hopgate_assets__/ 경로는 백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko)
// 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만, // 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만,
// 혹시라도 라우팅/구성이 꼬여서 이쪽으로 들어오는 경우를 방지하기 위해 // 혹시라도 라우팅/구성이 꼬여서 이쪽으로 들어오는 경우를 방지하기 위해
// /__hopgate_assets__/ 요청은 여기서도 강제로 정적 핸들러로 처리합니다. (ko) // /__hopgate_assets__/ 요청은 여기서도 강제로 정적 핸들러로 처리합니다. (ko)
// //
// The /__hopgate_assets__/ path must always serve static assets independently // The /__hopgate_assets__/ path must always serve static assets independently
// of DTLS/backend state. This handler is intended for the generic proxy path (/), // of backend state. This handler is intended for the generic proxy path (/),
// but as a safety net, we short-circuit asset requests here as well. (en) // but as a safety net, we short-circuit asset requests here as well. (en)
if strings.HasPrefix(r.URL.Path, "/__hopgate_assets__/") { if strings.HasPrefix(r.URL.Path, "/__hopgate_assets__/") {
if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil { if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil {
@@ -426,7 +208,7 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
// 상태 코드 캡처를 위한 래퍼 // 상태 코드 캡처를 위한 래퍼
sr := &statusRecorder{ sr := &statusRecorder{
ResponseWriter: w, ResponseWriter: w,
status: http.StatusOK, status: 0,
} }
// 보안/식별 헤더를 공통으로 설정합니다. (ko) // 보안/식별 헤더를 공통으로 설정합니다. (ko)
// Configure common security and identity headers. (en) // Configure common security and identity headers. (en)
@@ -490,8 +272,10 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
return return
} }
// 2. 일반 HTTP 요청은 DTLS 를 통해 클라이언트로 포워딩 // 2. 일반 HTTP 요청은 활성 yamux 터널을 통해 클라이언트로 포워딩합니다. (ko)
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. // 2. Regular HTTP requests are forwarded to clients over an active yamux tunnel. (en)
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. (ko)
// For now, use a fixed logical service name "web"; this can be improved with a Router later. (en)
serviceName := "web" serviceName := "web"
// Host 헤더에서 포트를 제거하고 소문자로 정규화합니다. // Host 헤더에서 포트를 제거하고 소문자로 정규화합니다.
@@ -514,14 +298,14 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
return return
} }
sessWrapper := getSessionForHost(hostLower) activeTunnel := getTunnelForHost(hostLower)
if sessWrapper == nil { if activeTunnel == nil {
log.Warn("no dtls session for host", logging.Fields{ log.Warn("no tunnel for host", logging.Fields{
"host": r.Host, "host": r.Host,
}) })
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc() observability.ProxyErrorsTotal.WithLabelValues("no_tunnel_session").Inc()
// 등록되지 않았거나 활성 세션이 없는 도메인으로의 요청은 404 로 응답합니다. (ko) // 등록되지 않았거나 활성 터널이 없는 도메인으로의 요청은 404 로 응답합니다. (ko)
// Requests for hosts without an active DTLS session return 404. (en) // Requests for hosts without an active tunnel return 404. (en)
writeErrorPage(sr, r, http.StatusNotFound) writeErrorPage(sr, r, http.StatusNotFound)
return return
} }
@@ -549,88 +333,63 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
} }
} }
// r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 // r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 (ko)
// r.Body is consumed inside ForwardHTTP; ensure it is closed here. (en)
defer r.Body.Close() defer r.Body.Close()
// 서버 측에서 DTLS → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해 // 서버 측에서 yamux 터널 → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해
// 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며, // 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며,
// HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko) // HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko)
// Apply an overall timeout (default 15s, configurable via // Apply an overall timeout (default 15s, configurable via
// HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the DTLS forward path so that // HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the tunnel forward path so that
// excessively slow backends surface as gateway timeouts. (en) // excessively slow backends surface as gateway timeouts. (en)
ctx := r.Context() ctx := r.Context()
if proxyTimeout > 0 { if proxyTimeout > 0 && !isSSERequest(r) {
var cancel context.CancelFunc var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, proxyTimeout) ctx, cancel = context.WithTimeout(ctx, proxyTimeout)
defer cancel() defer cancel()
} }
type forwardResult struct { if isExtendedConnectWebSocketRequest(r) {
resp *protocol.Response extendedTunnel, ok := activeTunnel.(extendedConnectForwarder)
err error if !ok {
} writeErrorPage(sr, r, http.StatusNotImplemented)
resultCh := make(chan forwardResult, 1)
go func() {
select {
case <-ctx.Done():
// Context cancelled, do not proceed.
return return
default:
resp, err := sessWrapper.ForwardHTTP(ctx, logger, r, serviceName)
resultCh <- forwardResult{resp: resp, err: err}
} }
}() if err := extendedTunnel.ForwardExtendedConnect(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 {
log.Error("HTTP/2 Extended CONNECT forwarding failed", logging.Fields{"error": err.Error()})
var protoResp *protocol.Response
select {
case <-ctx.Done():
log.Error("forward over dtls timed out", logging.Fields{
"timeout_seconds": int64(proxyTimeout.Seconds()),
"error": ctx.Err().Error(),
})
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_timeout").Inc()
writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
return
case res := <-resultCh:
if res.err != nil {
log.Error("forward over dtls failed", logging.Fields{
"error": res.err.Error(),
})
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_failed").Inc()
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed) writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
}
return return
} }
protoResp = res.resp
if isWebSocketRequest(r) {
wsTunnel, ok := activeTunnel.(websocketForwarder)
if !ok {
writeErrorPage(sr, r, http.StatusNotImplemented)
return
}
if err := wsTunnel.ForwardWebSocket(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 {
log.Error("WebSocket forwarding failed", logging.Fields{"error": err.Error()})
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
}
return
} }
// 응답 헤더/바디 복원 if err := activeTunnel.ForwardHTTP(ctx, logger, r, serviceName, sr); err != nil && sr.status == 0 {
for k, vs := range protoResp.Header { log.Error("forward over tunnel failed", logging.Fields{"error": err.Error()})
// HopGate 가 소유한 보안/식별 헤더는 백엔드 값 대신 서버 값만 사용합니다. (ko) if ctx.Err() != nil {
// For security/identity headers owned by HopGate, ignore backend values. (en) observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_timeout").Inc()
if _, ok := hopGateOwnedHeaders[http.CanonicalHeaderKey(k)]; ok { writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
continue } else {
} observability.ProxyErrorsTotal.WithLabelValues("tunnel_forward_failed").Inc()
for _, v := range vs { writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
sr.Header().Add(k, v)
}
}
if protoResp.Status == 0 {
protoResp.Status = http.StatusOK
}
sr.WriteHeader(protoResp.Status)
if len(protoResp.Body) > 0 {
if _, err := sr.Write(protoResp.Body); err != nil {
log.Warn("failed to write http response body", logging.Fields{
"error": err.Error(),
})
} }
return
} }
log.Info("http request completed", logging.Fields{ log.Info("http request completed", logging.Fields{
"status": protoResp.Status, "status": sr.status,
"elapsed_ms": time.Since(start).Milliseconds(), "elapsed_ms": time.Since(start).Milliseconds(),
"service_name": serviceName, "service_name": serviceName,
}) })
@@ -640,10 +399,8 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
func main() { func main() {
logger := logging.NewStdJSONLogger("server") logger := logging.NewStdJSONLogger("server")
// Prometheus 메트릭 등록
observability.MustRegister()
// 1. 서버 설정 로드 (.env + 환경변수) // 1. 서버 설정 로드 (.env + 환경변수)
// internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다.
cfg, err := config.LoadServerConfigFromEnv() cfg, err := config.LoadServerConfigFromEnv()
if err != nil { if err != nil {
logger.Error("failed to load server config from env", logging.Fields{ logger.Error("failed to load server config from env", logging.Fields{
@@ -652,11 +409,38 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선)
httpListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTP_LISTEN")
httpsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTPS_LISTEN")
domainEnv := getEnvOrPanic(logger, "HOP_SERVER_DOMAIN")
debugEnv := getEnvOrPanic(logger, "HOP_SERVER_DEBUG")
// 디버깅 플래그 형식 확인
if debugEnv != "true" && debugEnv != "false" {
logger.Error("invalid value for HOP_SERVER_DEBUG; must be 'true' or 'false'", logging.Fields{
"env": "HOP_SERVER_DEBUG",
"value": debugEnv,
})
os.Exit(1)
}
// 유효성 검사 결과를 구조화 로그로 출력
logger.Info("validated server env vars", logging.Fields{
"HOP_SERVER_HTTP_LISTEN": httpListenEnv,
"HOP_SERVER_HTTPS_LISTEN": httpsListenEnv,
"HOP_SERVER_DOMAIN": domainEnv,
"HOP_SERVER_DEBUG": debugEnv,
})
// Prometheus 메트릭 등록
observability.MustRegister()
logger.Info("hop-gate server starting", logging.Fields{ logger.Info("hop-gate server starting", logging.Fields{
"stack": "prometheus-loki-grafana", "stack": "prometheus-loki-grafana",
"version": version,
"http_listen": cfg.HTTPListen, "http_listen": cfg.HTTPListen,
"https_listen": cfg.HTTPSListen, "https_listen": cfg.HTTPSListen,
"dtls_listen": cfg.DTLSListen, "tunnel_listen": cfg.TunnelListen,
"domain": cfg.Domain, "domain": cfg.Domain,
"debug": cfg.Debug, "debug": cfg.Debug,
}) })
@@ -689,136 +473,35 @@ func main() {
}) })
} }
// 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되 // yamux control stream에서 사용할 도메인 검증기 구성. (ko)
// ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다. // Construct domain validator for the yamux control stream. (en)
// 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed domainValidator := admin.NewEntDomainValidator(logger, dbClient)
// but ACME is still attempted in staging mode.
var tlsCfg *tls.Config
// ACME 를 위해 사용할 도메인 목록 구성
var domains []string var domains []string
if cfg.Domain != "" { if cfg.Domain != "" {
domains = append(domains, cfg.Domain) domains = append(domains, cfg.Domain)
} }
domains = append(domains, cfg.ProxyDomains...) domains = append(domains, cfg.ProxyDomains...)
// Debug 모드에서는 반드시 Staging CA 를 사용하도록 강제
if cfg.Debug { if cfg.Debug {
_ = os.Setenv("HOP_ACME_USE_STAGING", "true") _ = os.Setenv("HOP_ACME_USE_STAGING", "true")
} }
standaloneOnly := strings.EqualFold(strings.TrimSpace(os.Getenv("HOP_ACME_STANDALONE_ONLY")), "true")
// HOP_ACME_STANDALONE_ONLY=true 인 경우, ACME 인증서만 발급/갱신하고 프로세스를 종료합니다.
// 이 모드는 HTTP/DTLS 서버를 띄우지 않고 lego(ACME client)만 단독으로 실행할 때 사용합니다.
standaloneOnly := func() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_ACME_STANDALONE_ONLY")))
switch v {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}()
if standaloneOnly { if standaloneOnly {
logger.Info("running ACME standalone-only mode", logging.Fields{
"domains": domains,
"use_staging": cfg.Debug,
})
// ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장
// 이 호출이 끝나면 해당 도메인에 대한 인증서가 HOP_ACME_CACHE_DIR 에 준비되어 있어야 합니다.
acmeCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) acmeCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
if _, err := acme.NewLegoManagerFromEnv(acmeCtx, logger, domains); err != nil { if _, err := acme.NewLegoManagerFromEnv(acmeCtx, logger, domains); err != nil {
logger.Error("acme standalone mode failed", logging.Fields{ logger.Error("acme standalone mode failed", logging.Fields{"error": err.Error()})
"error": err.Error(),
"domains": domains,
})
os.Exit(1) os.Exit(1)
} }
logger.Info("acme standalone mode completed successfully, exiting process", logging.Fields{
"domains": domains,
})
return return
} }
// ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장
acmeMgr, err := acme.NewLegoManagerFromEnv(ctx, logger, domains) acmeMgr, err := acme.NewLegoManagerFromEnv(ctx, logger, domains)
if err != nil { if err != nil {
logger.Error("failed to initialize ACME lego manager", logging.Fields{ logger.Error("failed to initialize ACME lego manager", logging.Fields{"error": err.Error(), "domains": domains})
"error": err.Error(),
"domains": domains,
})
os.Exit(1) os.Exit(1)
} }
acmeTLSCfg := acmeMgr.TLSConfig() acmeTLSCfg := acmeMgr.TLSConfig()
logger.Info("acme tls config initialized", logging.Fields{
"domains": domains,
"use_staging": cfg.Debug,
})
if cfg.Debug {
// Debug 모드: DTLS 자체는 self-signed localhost 인증서를 사용하지만,
// ACME Staging 을 통해 실제 도메인 인증서도 동시에 관리합니다.
tlsCfg, err = dtls.NewSelfSignedLocalhostConfig()
if err != nil {
logger.Error("failed to create self-signed localhost cert", logging.Fields{
"error": err.Error(),
})
os.Exit(1)
}
logger.Warn("using self-signed localhost certificate for DTLS (debug mode)", logging.Fields{
"note": "acme is running in staging mode; do not use this configuration in production",
})
} else {
// Production 모드: DTLS/HTTPS 모두 ACME 인증서를 직접 사용
tlsCfg = acmeTLSCfg
}
// DTLS 서버는 HOP_SERVER_DOMAIN 으로 지정된 도메인에 대한 연결만 수락해야 합니다.
// 이를 위해 GetCertificate 를 래핑하여 SNI 검증 로직을 추가합니다.
// 주의: HTTPS 서버용 tlsCfg 에 영향을 주지 않도록 Clone()을 사용합니다.
dtlsTLSConfig := tlsCfg.Clone()
if cfg.Domain != "" {
nextGetCert := dtlsTLSConfig.GetCertificate
dtlsTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// SNI 검증: 설정된 도메인과 일치하지 않으면 핸드셰이크 거부
// ServerName이 비어있는 경우(클라이언트가 SNI 미전송 시)는 검증을 건너뜁니다.
if hello.ServerName != "" && !strings.EqualFold(hello.ServerName, cfg.Domain) {
return nil, fmt.Errorf("dtls: invalid SNI %q, expected %q", hello.ServerName, cfg.Domain)
}
// 기존 로직 수행
if nextGetCert != nil {
return nextGetCert(hello)
}
// Debug 모드 등에서 GetCertificate 가 없는 경우 Certificates 필드 사용
if len(dtlsTLSConfig.Certificates) > 0 {
return &dtlsTLSConfig.Certificates[0], nil
}
return nil, fmt.Errorf("dtls: no certificate found for %q", hello.ServerName)
}
}
// 4. DTLS 서버 리스너 생성 (pion/dtls 기반)
dtlsServer, err := dtls.NewPionServer(dtls.PionServerConfig{
Addr: cfg.DTLSListen,
TLSConfig: dtlsTLSConfig,
})
if err != nil {
logger.Error("failed to start dtls server", logging.Fields{
"error": err.Error(),
})
os.Exit(1)
}
defer dtlsServer.Close()
logger.Info("dtls server listening", logging.Fields{
"addr": cfg.DTLSListen,
})
// 5. HTTP / HTTPS 서버 시작 // 5. HTTP / HTTPS 서버 시작
// 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며, // 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며,
// 기본값은 15초입니다. (ko) // 기본값은 15초입니다. (ko)
@@ -893,9 +576,34 @@ func main() {
adminHandler.RegisterRoutes(adminMux) adminHandler.RegisterRoutes(adminMux)
httpMux.Handle("/api/v1/admin/", hostDomainHandler(allowedDomain, logger, adminMux)) httpMux.Handle("/api/v1/admin/", hostDomainHandler(allowedDomain, logger, adminMux))
// 기본 HTTP → DTLS Proxy 엔트리 포인트 // 기본 HTTP → yamux Proxy 엔트리 포인트
httpMux.Handle("/", httpHandler) httpMux.Handle("/", httpHandler)
// HTTP/3 uses the same ingress handler and certificates as HTTPS, but listens
// on UDP separately from the TCP listener.
if len(acmeTLSCfg.NextProtos) == 0 {
acmeTLSCfg.NextProtos = []string{"h2", "http/1.1"}
}
http3Server := &http3.Server{
Addr: cfg.HTTPSListen,
Handler: nil,
TLSConfig: http3.ConfigureTLSConfig(acmeTLSCfg.Clone()),
}
publicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor < 3 {
_ = http3Server.SetQUICHeaders(w.Header())
}
httpMux.ServeHTTP(w, r)
})
http3Server.Handler = publicHandler
go func() {
if err := serveYamuxTunnel(context.Background(), cfg.TunnelListen, acmeTLSCfg, logger, domainValidator); err != nil {
logger.Error("yamux tunnel server stopped", logging.Fields{"error": err.Error()})
}
}()
logger.Info("yamux transport enabled", logging.Fields{"listen": cfg.TunnelListen})
// HTTP: 평문 포트 // HTTP: 평문 포트
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: cfg.HTTPListen, Addr: cfg.HTTPListen,
@@ -912,10 +620,9 @@ func main() {
} }
}() }()
// HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능)
httpsSrv := &http.Server{ httpsSrv := &http.Server{
Addr: cfg.HTTPSListen, Addr: cfg.HTTPSListen,
Handler: httpMux, Handler: publicHandler,
TLSConfig: acmeTLSCfg, TLSConfig: acmeTLSCfg,
} }
go func() { go func() {
@@ -929,89 +636,14 @@ func main() {
} }
}() }()
// 6. 도메인 검증기 준비 (ent + PostgreSQL 기반 실제 구현) go func() {
// Admin Plane 에서 관리하는 Domain 테이블을 사용해 (domain, client_api_key) 조합을 검증합니다. logger.Info("http/3 server listening", logging.Fields{"addr": cfg.HTTPSListen})
domainValidator := admin.NewEntDomainValidator(logger, dbClient) if err := http3Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http/3 server error", logging.Fields{"error": err.Error()})
// DTLS 핸드셰이크 단계에서는 클라이언트가 제시한 도메인의 DNS(A/AAAA)가
// HOP_ACME_EXPECT_IPS 에 설정된 IP들 중 하나 이상을 가리키는지 추가로 검증합니다. (ko)
// During DTLS handshake, additionally verify that the presented domain resolves
// (via A/AAAA) to at least one IP configured in HOP_ACME_EXPECT_IPS. (en)
// EXPECT_IPS 가 비어 있으면 DNS 기반 검증은 생략하고 DB 검증만 수행합니다. (ko)
// If EXPECT_IPS is empty, only DB-based validation is performed. (en)
expectedHandshakeIPs := parseExpectedIPsFromEnv(logger, "HOP_ACME_EXPECT_IPS")
var validator dtls.DomainValidator = &domainGateValidator{
expectedIPs: expectedHandshakeIPs,
inner: domainValidator,
logger: logger,
} }
}()
// 7. DTLS Accept 루프 + Handshake // yamux 및 HTTP/HTTPS 서버 goroutine을 유지합니다. (ko)
for { // Keep the yamux and HTTP/HTTPS server goroutines running. (en)
sess, err := dtlsServer.Accept() select {}
if err != nil {
logger.Error("dtls accept failed", logging.Fields{
"error": err.Error(),
})
continue
}
// 각 세션별로 goroutine 에서 핸드셰이크 및 후속 처리를 수행합니다.
go func(s dtls.Session) {
// NOTE: 세션은 HTTP↔DTLS 터널링에 계속 사용해야 하므로 이곳에서 Close 하지 않습니다.
// 세션 종료/타임아웃 관리는 별도의 세션 매니저(TODO)에서 담당해야 합니다.
hsRes, err := dtls.PerformServerHandshake(ctx, s, validator, logger)
if err != nil {
// 핸드셰이크 실패 메트릭 기록
observability.DTLSHandshakesTotal.WithLabelValues("failure").Inc()
// PerformServerHandshake 내부에서 이미 상세 로그를 남기므로 여기서는 요약만 기록합니다.
logger.Warn("dtls handshake failed", logging.Fields{
"session_id": s.ID(),
"error": err.Error(),
})
// 핸드셰이크 실패 시 세션을 명시적으로 종료하여 invalid SNI 등 오류에서
// 연결이 열린 채로 남지 않도록 합니다.
_ = s.Close()
return
}
// Handshake 성공 메트릭 기록
observability.DTLSHandshakesTotal.WithLabelValues("success").Inc()
// Handshake 성공: 서버 측은 어떤 도메인이 연결되었는지 알 수 있습니다.
logger.Info("dtls handshake completed", logging.Fields{
"session_id": s.ID(),
"domain": hsRes.Domain,
})
// Handshake 가 완료된 세션을 도메인에 매핑해 HTTP 요청 시 사용할 수 있도록 등록합니다.
registerSessionForDomain(hsRes.Domain, s, logger)
// Handshake 가 정상적으로 끝난 이후, 실제로 해당 도메인에 대해 ACME 인증서를 확보/연장합니다.
// Debug 모드에서도 ACME 는 항상 시도하지만, 위에서 HOP_ACME_USE_STAGING=true 로 설정되어
// Staging CA 를 사용하게 됩니다.
if hsRes.Domain != "" {
go func(domain string) {
acmeLogger := logger.With(logging.Fields{
"component": "acme_post_handshake",
"domain": domain,
"debug": cfg.Debug,
})
if _, err := acme.NewLegoManagerFromEnv(context.Background(), acmeLogger, []string{domain}); err != nil {
acmeLogger.Error("failed to ensure acme certificate after dtls handshake", logging.Fields{
"error": err.Error(),
})
return
}
acmeLogger.Info("acme certificate ensured after dtls handshake", nil)
}(hsRes.Domain)
}
// TODO:
// - hsRes.Domain 과 연결된 세션을 proxy 레이어에 등록
// - HTTP 요청을 이 세션을 통해 해당 클라이언트로 라우팅
// - 세션 생명주기/타임아웃 관리 등
}(sess)
}
} }
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dalbodeule/hop-gate/internal/logging"
)
type noopLogger struct{}
func (noopLogger) Debug(string, logging.Fields) {}
func (noopLogger) Info(string, logging.Fields) {}
func (noopLogger) Warn(string, logging.Fields) {}
func (noopLogger) Error(string, logging.Fields) {}
func (l noopLogger) With(logging.Fields) logging.Logger { return l }
type streamingTestTunnel struct {
forwardHTTPCalled bool
extendedConnectCalled bool
deadlineSeen bool
}
func (t *streamingTestTunnel) ForwardHTTP(ctx context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error {
t.forwardHTTPCalled = true
_, t.deadlineSeen = ctx.Deadline()
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("data: ready\n\n"))
return err
}
func (t *streamingTestTunnel) ForwardExtendedConnect(_ context.Context, _ logging.Logger, _ *http.Request, _ string, w http.ResponseWriter) error {
t.extendedConnectCalled = true
w.WriteHeader(http.StatusOK)
return nil
}
func TestSSERequestStreamsWithoutProxyTimeout(t *testing.T) {
tunnel := &streamingTestTunnel{}
logger := noopLogger{}
domain := "sse-test.example"
registerTunnelForDomain(domain, tunnel, logger)
defer unregisterTunnelForDomain(domain, tunnel, logger)
req := httptest.NewRequest(http.MethodGet, "http://"+domain+"/events", nil)
req.Host = domain
req.Header.Set("Accept", "text/event-stream")
recorder := httptest.NewRecorder()
newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req)
if !tunnel.forwardHTTPCalled {
t.Fatal("expected SSE request to use HTTP forwarder")
}
if tunnel.deadlineSeen {
t.Fatal("expected SSE request to avoid the normal proxy timeout")
}
if got := recorder.Header().Get("Content-Type"); got != "text/event-stream" {
t.Fatalf("Content-Type = %q, want text/event-stream", got)
}
if got := recorder.Body.String(); got != "data: ready\n\n" {
t.Fatalf("body = %q, want SSE event", got)
}
}
func TestHTTP2ExtendedConnectUsesDedicatedForwarder(t *testing.T) {
tunnel := &streamingTestTunnel{}
logger := noopLogger{}
domain := "h2-connect-test.example"
registerTunnelForDomain(domain, tunnel, logger)
defer unregisterTunnelForDomain(domain, tunnel, logger)
req := httptest.NewRequest(http.MethodConnect, "https://"+domain+"/socket", nil)
req.Host = domain
req.ProtoMajor = 2
req.ProtoMinor = 0
req.Proto = "websocket"
recorder := httptest.NewRecorder()
newHTTPHandler(logger, time.Second).ServeHTTP(recorder, req)
if !tunnel.extendedConnectCalled {
t.Fatal("expected HTTP/2 Extended CONNECT forwarder to be called")
}
if tunnel.forwardHTTPCalled {
t.Fatal("did not expect regular HTTP forwarder for Extended CONNECT")
}
if got := recorder.Code; got != http.StatusOK {
t.Fatalf("status = %d, want %d", got, http.StatusOK)
}
}
func TestHTTP3ExtendedConnectDetection(t *testing.T) {
req := httptest.NewRequest(http.MethodConnect, "https://h3.example/socket", nil)
req.ProtoMajor = 3
req.ProtoMinor = 0
req.Proto = "websocket"
if !isExtendedConnectWebSocketRequest(req) {
t.Fatal("expected HTTP/3 Extended CONNECT WebSocket request to be detected")
}
}
func TestHTTP3SSERequestUsesStreamingPolicy(t *testing.T) {
tunnel := &streamingTestTunnel{}
logger := noopLogger{}
domain := "h3-sse-test.example"
registerTunnelForDomain(domain, tunnel, logger)
defer unregisterTunnelForDomain(domain, tunnel, logger)
req := httptest.NewRequest(http.MethodGet, "https://"+domain+"/events", nil)
req.Host = domain
req.ProtoMajor = 3
req.ProtoMinor = 0
req.Header.Set("Accept", "text/event-stream")
recorder := httptest.NewRecorder()
newHTTPHandler(logger, time.Nanosecond).ServeHTTP(recorder, req)
if tunnel.deadlineSeen {
t.Fatal("expected HTTP/3 SSE request to avoid the normal proxy timeout")
}
if got := recorder.Body.String(); got != "data: ready\n\n" {
t.Fatalf("body = %q, want SSE event", got)
}
}
+300
View File
@@ -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:
}
}
}
-1
View File
@@ -27,7 +27,6 @@ services:
# 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준) # 외부 80/443 → 컨테이너 8080/8443 매핑 (예: .env.example 기준)
- "80:80" # HTTP - "80:80" # HTTP
- "443:443" # HTTPS (TCP) - "443:443" # HTTPS (TCP)
- "443:443/udp" # DTLS (UDP)
volumes: volumes:
# ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관) # ACME 인증서/계정 캐시 디렉터리 (호스트에 지속 보관)
+13 -12
View File
@@ -1,15 +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
) )
require ( require (
@@ -19,26 +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 google.golang.org/protobuf v1.36.10 // indirect
) )
+32 -28
View File
@@ -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
View File
@@ -4,7 +4,7 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
=== High-level concept === === High-level concept ===
- HopGate is a reverse HTTP gateway. - HopGate is a reverse HTTP gateway.
- A single public server terminates HTTPS and DTLS, and tunnels HTTP traffic to multiple clients. - A single public server terminates HTTPS and exposes a tunnel endpoint (gRPC/HTTP2) to tunnel HTTP traffic to multiple clients.
- Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT). - Each client runs in a private network and forwards HTTP requests to local services (127.0.0.1:PORT).
=== Main components to draw === === Main components to draw ===
@@ -19,8 +19,8 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Terminates TLS using ACME certificates for main and proxy domains. - Terminates TLS using ACME certificates for main and proxy domains.
b. "HTTP Listener (TCP 80)" b. "HTTP Listener (TCP 80)"
- Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS. - Handles ACME HTTP-01 challenges and redirects HTTP to HTTPS.
c. "DTLS Listener (UDP 443 or 8443)" c. "Tunnel Endpoint (gRPC)"
- Terminates DTLS sessions from multiple clients. - gRPC/HTTP2 listener on the same HTTPS port (TCP 443) tunnel streams.
d. "Admin API / Management Plane" d. "Admin API / Management Plane"
- REST API base path: /api/v1/admin - REST API base path: /api/v1/admin
- Endpoints: - Endpoints:
@@ -31,8 +31,9 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Routes incoming HTTP(S) requests to the correct client based on domain and path. - Routes incoming HTTP(S) requests to the correct client based on domain and path.
f. "ACME Certificate Manager" f. "ACME Certificate Manager"
- Automatically issues and renews TLS certificates (Let's Encrypt). - Automatically issues and renews TLS certificates (Let's Encrypt).
g. "DTLS Session Manager" g. "Tunnel Session Manager"
- Manages DTLS connections and per-domain sessions with clients. - Manages tunnel connections and per-domain sessions with clients
(gRPC streams).
h. "Metrics & Logging" h. "Metrics & Logging"
- Structured JSON logs shipped to Prometheus / Loki / Grafana stack. - Structured JSON logs shipped to Prometheus / Loki / Grafana stack.
@@ -50,35 +51,34 @@ Please draw a clean, modern system architecture diagram for a project called "Ho
- Draw 23 separate client boxes to show that multiple clients can connect. - Draw 23 separate client boxes to show that multiple clients can connect.
- Each box titled "HopGate Client". - Each box titled "HopGate Client".
- Inside each client box, show: - Inside each client box, show:
a. "DTLS Client" a. "Tunnel Client"
- Connects to HopGate Server via DTLS. - gRPC client that opens a long-lived bi-directional gRPC stream over HTTPS (HTTP/2).
- Performs handshake with:
- domain
- client_api_key
b. "Client Proxy" b. "Client Proxy"
- Receives HTTP requests from the server over DTLS. - Receives HTTP request frames from the server over the tunnel (gRPC stream).
- Forwards them to local services such as: - Forwards them to local services such as:
- 127.0.0.1:8080 (web) - 127.0.0.1:8080 (web)
- 127.0.0.1:9000 (admin)
c. "Local Services" c. "Local Services"
- A small group of boxes representing local HTTP servers. - A small group of boxes representing local HTTP servers.
=== Flows to highlight === === Flows to highlight ===
1) User HTTP Flow 1) User HTTP Flow
- External user -> HTTPS Listener -> Reverse Proxy Core -> DTLS Session Manager -> Specific HopGate Client -> Local Service -> back through same path to the user. - External user -> HTTPS Listener -> Reverse Proxy Core ->
gRPC Tunnel Endpoint -> specific HopGate Client (gRPC stream) -> Local Service ->
back through same path to the user.
2) Admin Flow 2) Admin Flow
- Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM: - Administrator -> Admin API (with Bearer admin key) -> PostgreSQL + ent ORM:
- Register domain + memo -> returns client_api_key. - Register domain + memo -> returns client_api_key.
- Unregister domain + client_api_key. - Unregister domain + client_api_key.
3) DTLS Handshake Flow 3) Tunnel Handshake / Session Establishment Flow
- From client to server over DTLS: - v1: DTLS Handshake Flow (legacy) - (REMOVED)
- Client sends {domain, client_api_key}. - v2: gRPC Tunnel Establishment Flow:
- Server validates against PostgreSQL Domain table. - From client to server over HTTPS (HTTP/2):
- On success, both sides log: - Client opens a long-lived bi-directional gRPC stream (e.g. OpenTunnel).
- server: which domain is bound to the session. - First frame includes {domain, client_api_key} and client metadata.
- client: success message, bound domain, and local_target (local service address). - Server validates against PostgreSQL Domain table and associates the gRPC stream with that domain.
- Subsequent frames carry HTTP request/response metadata and body chunks.
=== Visual style === === Visual style ===
- Clean flat design, no 3D. - Clean flat design, no 3D.
+2 -2
View File
@@ -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
} }
+2 -2
View File
@@ -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,
+12 -6
View File
@@ -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)
@@ -107,9 +107,13 @@ func loadDotEnvOnce() {
val = strings.Trim(val, `"'`) val = strings.Trim(val, `"'`)
if key != "" { if key != "" {
// 이미 OS 환경변수에 설정된 값이 있는 경우 이를 우선시하고,
// 비어 있는 키에 대해서만 .env 값을 주입합니다.
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val) _ = os.Setenv(key, val)
} }
} }
}
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
dotenvErr = err dotenvErr = err
return return
@@ -209,7 +213,8 @@ func loadLoggingFromEnv() LoggingConfig {
} }
} }
// LoadServerConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 서버 설정을 구성합니다. // LoadServerConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤
// "환경변수 > .env" 우선순위로 서버 설정을 구성합니다.
func LoadServerConfigFromEnv() (*ServerConfig, error) { func LoadServerConfigFromEnv() (*ServerConfig, error) {
loadDotEnvOnce() loadDotEnvOnce()
if dotenvErr != nil { if dotenvErr != nil {
@@ -219,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),
@@ -228,7 +233,8 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) {
return cfg, nil return cfg, nil
} }
// LoadClientConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 클라이언트 설정을 구성합니다. // LoadClientConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤
// "환경변수 > .env" 우선순위로 클라이언트 설정을 구성합니다.
// 실제 런타임에서 사용되는 필드는 ServerAddr, Domain, ClientAPIKey, LocalTarget 입니다. // 실제 런타임에서 사용되는 필드는 ServerAddr, Domain, ClientAPIKey, LocalTarget 입니다.
func LoadClientConfigFromEnv() (*ClientConfig, error) { func LoadClientConfigFromEnv() (*ClientConfig, error) {
loadDotEnvOnce() loadDotEnvOnce()
-23
View File
@@ -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 등을 사용해 추가합니다.
-210
View File
@@ -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)
}
-69
View File
@@ -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
}
-218
View File
@@ -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
}
-33
View File
@@ -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:]
}
+2 -2
View File
@@ -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

+2 -2
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css --> <!-- Tailwind CSS is served separately from /__hopgate_assets__/errors.css -->
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1
View File
@@ -5,6 +5,7 @@
<title>404 Not Found - HopGate</title> <title>404 Not Found - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1
View File
@@ -5,6 +5,7 @@
<title>500 Internal Server Error - HopGate</title> <title>500 Internal Server Error - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1
View File
@@ -5,6 +5,7 @@
<title>502 Bad Gateway - HopGate</title> <title>502 Bad Gateway - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1
View File
@@ -5,6 +5,7 @@
<title>504 Gateway Timeout - HopGate</title> <title>504 Gateway Timeout - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1
View File
@@ -5,6 +5,7 @@
<title>525 TLS Handshake Failed - HopGate</title> <title>525 TLS Handshake Failed - HopGate</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/__hopgate_assets__/errors.css"> <link rel="stylesheet" href="/__hopgate_assets__/errors.css">
<link rel="icon" href="/__hopgate_assets__/favicon.ico">
</head> </head>
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4"> <body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
<div class="w-full max-w-xl text-center"> <div class="w-full max-w-xl text-center">
+1 -11
View File
@@ -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,
-229
View File
@@ -1,229 +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
// 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 프레이밍으로 기록합니다.
// Encode encodes an Envelope as a length-prefixed protobuf message.
func (protobufCodec) Encode(w io.Writer, env *Envelope) error {
pbEnv, err := toProtoEnvelope(env)
if err != nil {
return err
}
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")
}
var lenBuf [4]byte
if len(data) > int(^uint32(0)) {
return fmt.Errorf("protobuf codec: envelope too large: %d bytes", len(data))
}
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data)))
if _, err := w.Write(lenBuf[:]); err != nil {
return fmt.Errorf("protobuf codec: write length prefix: %w", err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("protobuf codec: write payload: %w", err)
}
return nil
}
// Decode 는 length-prefix 프레임에서 Protobuf Envelope 를 읽어들여
// 내부 Envelope 구조체로 변환합니다.
// Decode reads a length-prefixed protobuf Envelope and converts it into the internal Envelope.
func (protobufCodec) Decode(r io.Reader, env *Envelope) error {
var lenBuf [4]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return fmt.Errorf("protobuf codec: read length prefix: %w", err)
}
n := binary.BigEndian.Uint32(lenBuf[:])
if n == 0 {
return fmt.Errorf("protobuf codec: zero-length envelope")
}
if n > maxProtoEnvelopeBytes {
return fmt.Errorf("protobuf codec: envelope too large: %d bytes (max %d)", n, maxProtoEnvelopeBytes)
}
buf := make([]byte, int(n))
if _, err := io.ReadFull(r, buf); err != nil {
return fmt.Errorf("protobuf codec: read payload: %w", err)
}
var pbEnv protocolpb.Envelope
if err := proto.Unmarshal(buf, &pbEnv); err != nil {
return fmt.Errorf("protobuf codec: unmarshal envelope: %w", err)
}
return fromProtoEnvelope(&pbEnv, env)
}
// DefaultCodec 은 현재 런타임에서 사용하는 기본 WireCodec 입니다.
// 이제 Protobuf 기반 codec 을 기본으로 사용합니다.
var DefaultCodec WireCodec = protobufCodec{}
// toProtoEnvelope 는 내부 Envelope 구조체를 Protobuf Envelope 로 변환합니다.
// 현재 구현은 MessageTypeHTTP (HTTPRequest/HTTPResponse) 만 지원하며,
// 스트림 관련 타입은 이후 스트림 터널링 구현 단계에서 확장합니다.
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")
default:
// 스트림 관련 타입은 아직 DTLS 스트림 터널링 구현 이전 단계이므로 지원하지 않습니다.
// Stream-based message types are not yet supported by the protobuf codec.
return nil, fmt.Errorf("protobuf codec: unsupported envelope type %q", env.Type)
}
}
// fromProtoEnvelope 는 Protobuf Envelope 를 내부 Envelope 구조체로 변환합니다.
// 현재 구현은 HTTP 요청/응답만 지원합니다.
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
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
return nil
default:
return fmt.Errorf("protobuf codec: unsupported payload type %T", payload)
}
}
-103
View File
@@ -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;
}
}
-799
View File
@@ -1,799 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.1
// source: internal/protocol/hopgate_stream.proto
package 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
}
-133
View File
@@ -1,133 +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
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
// 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"` // 비워두면 정상 종료로 해석
}
-235
View File
@@ -1,235 +0,0 @@
package proxy
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"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"
}
// 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,
}
}
// StartLoop 는 DTLS 세션에서 protocol.Envelope 를 읽고, HTTP 요청의 경우 로컬 HTTP 요청을 수행한 뒤
// protocol.Envelope(HTTP 응답 포함)을 다시 세션으로 쓰는 루프를 실행합니다. (ko)
// StartLoop reads protocol.Envelope messages from the DTLS session; for HTTP messages it
// performs local HTTP requests and writes back HTTP responses wrapped in an Envelope. (en)
func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
if ctx == nil {
ctx = context.Background()
}
log := p.Logger
// NOTE: pion/dtls 는 복호화된 애플리케이션 데이터를 호출자가 제공한 버퍼에 채워 넣습니다.
// 기본 JSON 디코더 버퍼(수백 바이트 수준)만 사용하면 큰 HTTP 바디/Envelope 에서
// "dtls: buffer too small" 오류가 날 수 있으므로, 여기서는 여유 있는 버퍼(64KiB)를 사용합니다. (ko)
// NOTE: pion/dtls decrypts application data into the buffer provided by the caller.
// Using only the default JSON decoder buffer (a few hundred bytes) can trigger
// "dtls: buffer too small" for large HTTP bodies/envelopes. The default
// JSON-based WireCodec internally wraps the DTLS session with a 64KiB
// bufio.Reader, matching this requirement. (en)
codec := protocol.DefaultCodec
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(sess, &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
}
// 현재는 HTTP 타입만 지원하며, 그 외 타입은 에러로 처리합니다.
if env.Type != protocol.MessageTypeHTTP || env.HTTPRequest == nil {
log.Error("received unsupported envelope type from server", logging.Fields{
"type": env.Type,
})
return fmt.Errorf("unsupported envelope type %q or missing http_request", env.Type)
}
req := env.HTTPRequest
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 := codec.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,
})
}
}
// 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 바디를
// 하나의 JSON Envelope 로 감싸 전송하는 현재 설계에서는 바디가 너무 크면
// OS 레벨에서 "message too long" (EMSGSIZE) 가 발생할 수 있습니다. (ko)
//
// 이를 피하기 위해, 터널링 가능한 바디 크기에 상한을 두고, 이를 초과하는
// 응답은 502 Bad Gateway + HopGate 전용 에러 메시지로 대체합니다. (ko)
//
// DTLS over UDP has an upper bound on datagram size (~64KiB). With the current
// design (wrapping the entire HTTP body into a single JSON envelope), very
// large bodies can trigger "message too long" (EMSGSIZE) at the OS level.
// To avoid this, we cap the tunneled body size and replace oversized responses
// with a 502 Bad Gateway + HopGate-specific error body. (en)
const maxTunnelBodyBytes = 48 * 1024 // 48KiB, conservative under UDP limits
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
}
-43
View File
@@ -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
}
+8
View File
@@ -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
}
+222
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+174 -150
View File
@@ -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
View File
@@ -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"
} }
} }
-502
View File
@@ -1,502 +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 연동 등은 아직 남아 있음.
Actual proxying (HTTP ↔ DTLS tunneling), admin API business logic, and real ACME integration are still pending.
---
## 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`).
- DummyDomainValidator 사용해 도메인/API Key 조합을 임시로 모두 허용.
- 클라이언트 메인: [`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 구조 정의 및 기본 에러 처리.
- 아직 실제 서비스/라우터 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
- [x] 서버 측 Proxy 구현 확장: [`internal/proxy/server.go`](internal/proxy/server.go)
- HTTP/HTTPS 리스너와 DTLS 세션 매핑 구현.
- `Router` 구현체 추가 (도메인/패스 → 클라이언트/서비스).
- 요청/응답을 `internal/protocol` 구조체로 직렬화/역직렬화.
- [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 위 애플리케이션 프로토콜을 **완전히 스트림/프레임 기반**으로 재설계합니다.
The current tunneling model uses a **single JSON envelope + single DTLS write per HTTP message**, which can hit UDP MTU limits (`sendto: message too long`) for large bodies.
Before production, we will redesign the application protocol over DTLS to be fully **stream/frame-based**.
고려해야 할 제약 / 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.
- [ ] MTU-safe chunk 크기 정의
- DTLS/UDP 헤더, JSON 인코딩 오버헤드를 고려해 안전한 payload 크기(예: 4KiB)를 상수로 정의합니다.
Define a safe payload size constant (e.g. 4KiB) considering DTLS/UDP headers and JSON overhead.
- 모든 HTTP 바디는 이 크기 이하의 chunk 로 잘라 `StreamData.Data` 에 담아 전송합니다.
All HTTP bodies must be sliced into chunks no larger than this and carried in `StreamData.Data`.
---
##### 3.3A.2 애플리케이션 레벨 ARQ 설계 (Selective Retransmission)
##### 3.3A.2 Application-level ARQ (Selective Retransmission)
- [x] 수신 측 스트림 상태 관리 로직 설계
- 스트림별로 다음 상태를 유지합니다.
For each stream, maintain:
- `expectedSeq` (다음에 연속으로 기대하는 Seq, 초기값 0)
`expectedSeq` next contiguous sequence expected (starts at 0)
- `received` (map[uint64][]byte) 도착했지만 아직 순서가 맞지 않은 chunk 버퍼
`received` buffer for out-of-order chunks
- `lastAckSent`, `lostBuffer` – 마지막 ACK 상태 및 누락 시퀀스 기록
`lastAckSent`, `lostBuffer` last acknowledged seq and known missing sequences
- `StreamData{ID, Seq, Data}` 수신 시:
When receiving `StreamData{ID, Seq, Data}`:
- `Seq == expectedSeq` 인 경우: 바로 상위(HTTP Body writer)에 전달 후,
`expectedSeq++` 하면서 `received` map 에 쌓인 연속된 Seq 들을 순서대로 flush.
If `Seq == expectedSeq`, deliver to the HTTP body writer, increment `expectedSeq`, and flush any contiguous buffered seqs.
- `Seq > expectedSeq` 인 경우: `received[Seq] = Data` 로 버퍼링하고,
`expectedSeq` ~ `Seq-1` 구간 중 비어 있는 Seq 들을 `lostBuffer` 에 추가.
If `Seq > expectedSeq`, buffer as out-of-order and mark missing seqs in `lostBuffer`.
- [x] 수신 측 StreamAck 전송 정책
- 주기적 타이머 또는 일정 수의 프레임 처리 후에 `StreamAck` 를 전송합니다.
Send `StreamAck` periodically or after processing N frames:
- `AckSeq = expectedSeq - 1` (연속 수신 완료 지점)
`AckSeq = expectedSeq - 1` last contiguous sequence received
- `LostSeqs` 는 윈도우 내 손실 시퀀스 중 상한 개수까지만 포함 (과도한 길이 방지).
`LostSeqs` should only include a bounded set of missing seqs within the receive window.
- [x] 송신 측 재전송 로직
- 스트림별로 다음 상태를 유지합니다.
For each stream on the sender:
- `sendSeq` – 송신에 사용할 다음 Seq (0부터 시작)
- `outstanding` map[seq]*FrameState (`data`, `lastSentAt`, `retryCount` 포함)
- 새 chunk 전송 시:
On new chunk:
- `seq := sendSeq`, `sendSeq++`, `outstanding[seq] = FrameState{...}`,
`StreamData{ID, Seq: seq, Data}` 전송.
- `StreamAck{AckSeq, LostSeqs}` 수신 시:
On receiving `StreamAck`:
- `seq <= AckSeq``outstanding` 항목은 **모두 삭제** (해당 지점까지 연속 수신으로 간주).
Delete all `outstanding` entries with `seq <= AckSeq`.
- `LostSeqs` 에 포함된 시퀀스는 즉시 재전송 (`retryCount++`, `lastSentAt = now` 업데이트).
Retransmit frames whose seqs are listed in `LostSeqs`.
- 타임아웃 기반 재전송:
Timeout-based retransmission:
- 주기적으로 `outstanding` 을 순회하며 `now - lastSentAt > RTO` 인 프레임을 재전송 (단순 고정 RTO 로 시작).
Periodically scan `outstanding` and retransmit frames that exceed a fixed RTO.
---
##### 3.3A.3 HTTP ↔ 스트림 매핑 (서버/클라이언트)
##### 3.3A.3 HTTP ↔ stream mapping (server/client)
- [x] 서버 → 클라이언트 요청 스트림: [`cmd/server/main.go`](cmd/server/main.go:200)
- 현재 `ForwardHTTP` 는 단일 `HTTPRequest`/`HTTPResponse` 를 처리하는 구조입니다.
Currently `ForwardHTTP` handles a single `HTTPRequest`/`HTTPResponse` pair.
- 스트림 모드에서는 다음과 같이 바꿉니다.
In stream mode:
- 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)
- [ ] JSON 기반 스트림 프로토콜의 1단계 구현/안정화 이후, 직렬화 포맷 재검토
- 현재는 디버깅/호환성 관점에서 JSON `Envelope` + base64 `[]byte` encoding 이 유리합니다.
For now, JSON `Envelope` + base64-encoded `[]byte` is convenient for debugging and compatibility.
- HTTP 바디 chunk 가 MTU-safe 크기(예: 4KiB)로 제한되므로, JSON 오버헤드는 수용 가능합니다.
Since body chunks are bounded to a safe MTU-sized payload, JSON overhead is acceptable initially.
- [ ] 필요 시 length-prefix 이진 프레임(Protobuf 등)으로 전환
- 동일한 logical model (`StreamOpen` / `StreamData(seq)` / `StreamClose` / `StreamAck`)을 유지한 채,
wire-format 만 Protobuf 또는 MsgPack 등의 length-prefix binary 프레이밍으로 교체할 수 있습니다.
We can later keep the same logical model and swap the wire format for Protobuf or other length-prefix binary framing.
- [x] 이 전환은 `internal/protocol` 내 직렬화 레이어를 얇은 abstraction 으로 감싸 구현할 수 있습니다.
- 현재는 [`internal/protocol/codec.go`](internal/protocol/codec.go:1) 에 `WireCodec` 인터페이스와 JSON 기반 `DefaultCodec` 을 도입하여,
추후 Protobuf/이진 포맷으로 교체할 때 호출자는 `protocol.DefaultCodec` 만 사용하도록 분리해 두었습니다.
- This has been prepared via [`internal/protocol/codec.go`](internal/protocol/codec.go:1), which introduces a `WireCodec` interface
and a JSON-based `DefaultCodec` so that future Protobuf/binary codecs can be swapped in behind the same API.
---
### 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 / 관측성
- [ ] Prometheus 메트릭 노출 및 서버 wiring
- `cmd/server/main.go` 에 Prometheus `/metrics` 엔드포인트 추가 (예: promhttp.Handler).
- DTLS 세션 수, DTLS 핸드셰이크 성공/실패 수, 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 (프락시 동작 완성)
- [ ] 서버 Proxy 코어 구현 및 HTTPS ↔ DTLS 라우팅.
- [ ] 클라이언트 Proxy 루프 구현 및 로컬 서비스 연동.
- [ ] 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 통합.
- [ ] 에러/리트라이/타임아웃 정책 정교화.
- [ ] 보안/구성 최종 점검 및 문서화.
---
`progress.md` 파일은 아키텍처/코드 변경에 따라 수시로 업데이트하며, Milestone 기준으로 완료 여부를 체크해 나가면 된다.
This `progress.md` file should be updated as the architecture and code evolve, using the milestones above as a checklist.
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# POSIX sh 버전의 hop-gate 서버 이미지 빌드 스크립트.
# VERSION 은 현재 git 커밋의 7글자 SHA 를 사용합니다.
set -eu
# 스크립트 위치 기준 리포 루트 계산
SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null 2>&1 && pwd)
REPO_ROOT="${SCRIPT_DIR}/.."
cd "${REPO_ROOT}"
# 현재 커밋 7글자 SHA, git 정보가 없으면 dev
VERSION=$(git rev-parse --short=7 HEAD 2>/dev/null || echo dev)
# 기본 이미지 이름 (첫 번째 인자로 override 가능)
# 예:
# ./tools/build_server_image.sh
# ./tools/build_server_image.sh my/image/name
IMAGE_NAME=${1:-ghcr.io/dalbodeule/hop-gate}
echo "Building hop-gate server image"
echo " context : ${REPO_ROOT}"
echo " image : ${IMAGE_NAME}:${VERSION}"
echo " version : ${VERSION}"
# docker buildx 사용 가능 여부 확인
if command -v docker >/dev/null 2>&1 && docker buildx version >/dev/null 2>&1; then
BUILD_CMD="docker buildx build"
else
BUILD_CMD="docker build"
fi
# 선택적 환경 변수:
# PLATFORM=linux/amd64,linux/arm64 # buildx 용
# PUSH=1 # buildx --push
PLATFORM_ARGS=""
if [ "${PLATFORM-}" != "" ]; then
PLATFORM_ARGS="--platform ${PLATFORM}"
fi
PUSH_ARGS=""
if [ "${PUSH-}" != "" ]; then
PUSH_ARGS="--push"
fi
# 실제 빌드 실행
# shellcheck disable=SC2086
${BUILD_CMD} \
${PLATFORM_ARGS} \
-f Dockerfile.server \
--build-arg VERSION="${VERSION}" \
-t "${IMAGE_NAME}:${VERSION}" \
-t "${IMAGE_NAME}:latest" \
${PUSH_ARGS} \
.