Compare commits

...

21 Commits

Author SHA1 Message Date
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
10 changed files with 1227 additions and 256 deletions

View File

@@ -20,6 +20,10 @@ CLIENT_BIN := $(BIN_DIR)/hop-gate-client
VERSION ?= $(shell git describe --tags --dirty --always 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
all: server client
@@ -66,6 +70,19 @@ docker-server:
@echo "Building server Docker image..."
docker build -f Dockerfile.server -t hop-gate-server:$(VERSION) .
check-env-server:
@if [ -z "$$HOP_SERVER_HTTP_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTP_LISTEN이 설정되지 않았습니다."; exit 1; fi
@if [ -z "$$HOP_SERVER_HTTPS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTPS_LISTEN가 설정되지 않았습니다."; exit 1; fi
@if [ -z "$$HOP_SERVER_DTLS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_DTLS_LISTEN가 설정되지 않았습니다."; exit 1; fi
@if [ -z "$$HOP_SERVER_DOMAIN" ]; then echo "필수 환경 변수 HOP_SERVER_DOMAIN가 설정되지 않았습니다."; exit 1; fi
check-env-client:
@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
# --- Protobuf code generation -------------------------------------------------
# Requires:
# - protoc (https://grpc.io/docs/protoc-installation/)

View File

@@ -11,15 +11,15 @@ HopGate is a gateway that provides a **DTLS-based HTTP tunnel** between a public
- 서버는 80/443 포트를 점유하고, ACME(Let's Encrypt 등)로 TLS 인증서를 자동 발급/갱신합니다.
The server listens on ports 80/443 and automatically issues/renews TLS certificates via ACME (e.g. Let's Encrypt).
- 서버–클라이언트 간 전송은 DTLS 위에서 이루어지며, 현재는 HTTP 요청/응답을 JSON 기반 메시지로 터널링합니다.
Transport between server and clients uses DTLS; currently HTTP requests/responses are tunneled as JSON-based messages.
- 서버–클라이언트 간 전송은 DTLS 위에서 이루어지며, 현재는 HTTP 요청/응답을 **Protobuf 기반 length-prefixed Envelope** 로 터널링합니다.
Transport between server and clients uses DTLS; HTTP requests/responses are tunneled as **Protobuf-based, length-prefixed envelopes**.
- 관리 Plane(REST API)을 통해 도메인 등록/해제 및 클라이언트 API Key 발급을 수행합니다.
An admin management plane (REST API) handles domain registration/unregistration and client API key issuance.
- 로그는 JSON 구조 형태로 stdout 에 출력되며, Prometheus + Loki + Grafana 스택에 친화적으로 설계되었습니다.
Logs are JSON-structured and designed to work well with a Prometheus + Loki + Grafana stack.
> 참고: 대용량 HTTP 바디에 대해서는 DTLS/UDP MTU 한계 때문에 스트림/프레임 기반 프로토콜로의 전환을 계획하고 있습니다. 자세한 내용은 `progress.md` 의 3.3A 섹션을 참고하세요. (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)
> 참고: 대용량 HTTP 바디에 대해서는 DTLS/UDP MTU 한계 때문에 **단일 Envelope** 로는 한계가 있으므로, `progress.md` 의 3.3A 섹션에 정리된 것처럼 `StreamOpen` / `StreamData` / `StreamClose` 기반의 스트림/프레임 터널링으로 점진적으로 전환할 예정입니다. (ko)
> Note: For very large HTTP bodies, a single-envelope model still hits DTLS/UDP MTU limits. As outlined in section 3.3A of `progress.md`, the plan is to gradually move to a stream/frame-based tunneling model using `StreamOpen` / `StreamData` / `StreamClose`. (en)
아키텍처 세부 내용은 [`ARCHITECTURE.md`](ARCHITECTURE.md)에 정리되어 있습니다.
Detailed architecture is documented in [`ARCHITECTURE.md`](ARCHITECTURE.md).
@@ -74,6 +74,49 @@ Build artifacts are created as `./bin/hop-gate-server` and `./bin/hop-gate-clien
---
### 3.3 환경변수와 .env 처리 (Environment variables and .env handling)
HopGate 는 공통 설정을 [`internal/config/config.go`](internal/config/config.go) 에서 로드하며,
**운영체제 환경변수(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_DTLS_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. DTLS 핸드셰이크 테스트 (Testing DTLS Handshake)
HopGate는 DTLS 위에서 **도메인 + 클라이언트 API Key** 기반의 애플리케이션 레벨 핸드셰이크를 수행합니다.

View File

@@ -15,6 +15,17 @@ import (
"github.com/dalbodeule/hop-gate/internal/proxy"
)
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
}
// maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다.
func maskAPIKey(key string) string {
if len(key) <= 8 {
@@ -36,15 +47,8 @@ func firstNonEmpty(values ...string) string {
func main() {
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 포함)에서 클라이언트 설정 로드
// internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다.
envCfg, err := config.LoadClientConfigFromEnv()
if err != nil {
logger.Error("failed to load client config from env", logging.Fields{
@@ -53,6 +57,39 @@ func main() {
os.Exit(1)
}
// 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선)
serverAddrEnv := getEnvOrPanic(logger, "HOP_CLIENT_SERVER_ADDR")
clientDomainEnv := getEnvOrPanic(logger, "HOP_CLIENT_DOMAIN")
apiKeyEnv := getEnvOrPanic(logger, "HOP_CLIENT_API_KEY")
localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET")
debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG")
// 디버깅 플래그 형식 확인
if debugEnv != "true" && debugEnv != "false" {
logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{
"env": "HOP_CLIENT_DEBUG",
"value": debugEnv,
})
os.Exit(1)
}
// 유효성 검사 결과를 구조화 로그로 출력
logger.Info("validated client env vars", logging.Fields{
"HOP_CLIENT_SERVER_ADDR": serverAddrEnv,
"HOP_CLIENT_DOMAIN": clientDomainEnv,
"HOP_CLIENT_API_KEY_MASK": maskAPIKey(apiKeyEnv),
"HOP_CLIENT_LOCAL_TARGET": localTargetEnv,
"HOP_CLIENT_DEBUG": debugEnv,
})
// CLI 인자 정의 (env 보다 우선 적용됨)
serverAddrFlag := flag.String("server-addr", "", "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()
// 2. CLI 인자 우선, env 후순위로 최종 설정 구성
finalCfg := &config.ClientConfig{
ServerAddr: firstNonEmpty(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(envCfg.ServerAddr)),

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
@@ -31,6 +32,18 @@ import (
type dtlsSessionWrapper struct {
sess dtls.Session
mu sync.Mutex
nextStreamID uint64
}
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
}
// canonicalizeDomainForDNS 는 DTLS 핸드셰이크에서 전달된 도메인 문자열을
@@ -155,8 +168,10 @@ func parseExpectedIPsFromEnv(logger logging.Logger, envKey string) []net.IP {
return result
}
// ForwardHTTP 는 단일 HTTP 요청을 DTLS 세션으로 포워딩하고 응답을 돌려받습니다.
// ForwardHTTP forwards a single HTTP request over the DTLS session and returns the response.
// ForwardHTTP 는 HTTP 요청을 DTLS 세션 위의 StreamOpen/StreamData/StreamClose 프레임으로 전송하고,
// 역방향 스트림 응답을 수신해 protocol.Response 로 반환합니다. (ko)
// ForwardHTTP forwards an HTTP request over the DTLS session using StreamOpen/StreamData/StreamClose
// frames and reconstructs the reverse stream into a protocol.Response. (en)
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()
@@ -165,78 +180,220 @@ func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Log
ctx = context.Background()
}
// 요청 본문 읽기
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
body = b
}
codec := protocol.DefaultCodec
// 간단한 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,
}
// 세션 내에서 고유한 StreamID 를 생성합니다. (ko)
// Generate a unique StreamID for this HTTP request within the DTLS session. (en)
streamID := w.nextHTTPStreamID()
log := logger.With(logging.Fields{
"component": "http_to_dtls",
"request_id": requestID,
"request_id": string(streamID),
"method": req.Method,
"url": req.URL.String(),
})
log.Info("forwarding http request over dtls", logging.Fields{
log.Info("forwarding http request over dtls (stream mode)", logging.Fields{
"host": req.Host,
"scheme": req.URL.Scheme,
})
// HTTP 요청을 Envelope 로 감싸서 전송합니다.
env := &protocol.Envelope{
Type: protocol.MessageTypeHTTP,
HTTPRequest: httpReq,
// 요청 헤더를 복사하고 pseudo-header 로 HTTP 메타데이터를 추가합니다. (ko)
// Copy request headers and attach HTTP metadata as pseudo-headers. (en)
hdr := make(map[string][]string, len(req.Header)+3)
for k, vs := range req.Header {
hdr[k] = append([]string(nil), vs...)
}
hdr[protocol.HeaderKeyMethod] = []string{req.Method}
if req.URL != nil {
hdr[protocol.HeaderKeyURL] = []string{req.URL.String()}
}
host := req.Host
if host == "" && req.URL != nil {
host = req.URL.Host
}
if host != "" {
hdr[protocol.HeaderKeyHost] = []string{host}
}
if err := protocol.DefaultCodec.Encode(w.sess, env); err != nil {
log.Error("failed to encode http envelope", logging.Fields{
// StreamOpen 전송: 어떤 서비스로 라우팅해야 하는지와 초기 헤더를 전달합니다. (ko)
// Send StreamOpen to indicate which service to route to and initial headers. (en)
openEnv := &protocol.Envelope{
Type: protocol.MessageTypeStreamOpen,
StreamOpen: &protocol.StreamOpen{
ID: streamID,
Service: serviceName,
TargetAddr: "",
Header: hdr,
},
}
if err := codec.Encode(w.sess, openEnv); err != nil {
log.Error("failed to encode stream_open 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{
// 요청 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko)
// Chunk the request body into 4KiB (StreamChunkSize) StreamData frames. (en)
var seq uint64
if req.Body != nil {
buf := make([]byte, protocol.StreamChunkSize)
for {
n, err := req.Body.Read(buf)
if n > 0 {
dataCopy := append([]byte(nil), buf[:n]...)
dataEnv := &protocol.Envelope{
Type: protocol.MessageTypeStreamData,
StreamData: &protocol.StreamData{
ID: streamID,
Seq: seq,
Data: dataCopy,
},
}
if err2 := codec.Encode(w.sess, dataEnv); err2 != nil {
log.Error("failed to encode stream_data envelope", logging.Fields{
"error": err2.Error(),
})
return nil, err2
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read http request body for streaming: %w", err)
}
}
}
// 바디 종료를 알리는 StreamClose 를 전송합니다. (ko)
// Send StreamClose to mark the end of the request body. (en)
closeReqEnv := &protocol.Envelope{
Type: protocol.MessageTypeStreamClose,
StreamClose: &protocol.StreamClose{
ID: streamID,
Error: "",
},
}
if err := codec.Encode(w.sess, closeReqEnv); err != nil {
log.Error("failed to encode request stream_close 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,
// 클라이언트로부터 역방향 스트림 응답을 수신합니다. (ko)
// Receive reverse stream response (StreamOpen + StreamData* + StreamClose). (en)
var (
resp protocol.Response
bodyBuf bytes.Buffer
gotOpen bool
statusCode = http.StatusOK
)
resp.RequestID = string(streamID)
resp.Header = make(map[string][]string)
for {
var env protocol.Envelope
if err := codec.Decode(w.sess, &env); err != nil {
log.Error("failed to decode stream response envelope", logging.Fields{
"error": err.Error(),
})
return nil, fmt.Errorf("unexpected envelope type %q or empty http_response", respEnv.Type)
return nil, err
}
protoResp := respEnv.HTTPResponse
switch env.Type {
case protocol.MessageTypeStreamOpen:
so := env.StreamOpen
if so == nil {
return nil, fmt.Errorf("stream_open response payload is nil")
}
if so.ID != streamID {
return nil, fmt.Errorf("unexpected stream_open for id %q (expected %q)", so.ID, streamID)
}
// 상태 코드 및 헤더 복원 (pseudo-header 제거). (ko)
// Restore status code and headers (strip pseudo-headers). (en)
statusStr := firstHeaderValue(so.Header, protocol.HeaderKeyStatus, strconv.Itoa(http.StatusOK))
if sc, err := strconv.Atoi(statusStr); err == nil && sc > 0 {
statusCode = sc
}
for k, vs := range so.Header {
if k == protocol.HeaderKeyMethod ||
k == protocol.HeaderKeyURL ||
k == protocol.HeaderKeyHost ||
k == protocol.HeaderKeyStatus {
continue
}
resp.Header[k] = append([]string(nil), vs...)
}
gotOpen = true
log.Info("received dtls response", logging.Fields{
"status": protoResp.Status,
"error": protoResp.Error,
case protocol.MessageTypeStreamData:
sd := env.StreamData
if sd == nil {
return nil, fmt.Errorf("stream_data response payload is nil")
}
if sd.ID != streamID {
return nil, fmt.Errorf("unexpected stream_data for id %q (expected %q)", sd.ID, streamID)
}
if len(sd.Data) > 0 {
if _, err := bodyBuf.Write(sd.Data); err != nil {
return nil, fmt.Errorf("buffer stream_data response: %w", err)
}
}
case protocol.MessageTypeStreamClose:
sc := env.StreamClose
if sc == nil {
return nil, fmt.Errorf("stream_close response payload is nil")
}
if sc.ID != streamID {
return nil, fmt.Errorf("unexpected stream_close for id %q (expected %q)", sc.ID, streamID)
}
// 스트림 종료: 지금까지 수신한 헤더/바디로 protocol.Response 를 완성합니다. (ko)
// Stream finished: complete protocol.Response using collected headers/body. (en)
resp.Status = statusCode
resp.Body = bodyBuf.Bytes()
resp.Error = sc.Error
log.Info("received stream http response over dtls", logging.Fields{
"status": resp.Status,
"error": resp.Error,
})
if !gotOpen {
return nil, fmt.Errorf("received stream_close without prior stream_open for stream %q", streamID)
}
return &resp, nil
return protoResp, nil
default:
return nil, fmt.Errorf("unexpected envelope type %q in stream response", env.Type)
}
}
}
// nextHTTPStreamID 는 DTLS 세션 내 HTTP 요청에 사용할 고유 StreamID 를 생성합니다. (ko)
// nextHTTPStreamID generates a unique StreamID for HTTP requests on this DTLS session. (en)
func (w *dtlsSessionWrapper) nextHTTPStreamID() protocol.StreamID {
id := w.nextStreamID
w.nextStreamID++
return protocol.StreamID(fmt.Sprintf("http-%d", id))
}
// firstHeaderValue 는 map[string][]string 형태의 헤더에서 첫 번째 값을 반환하고,
// 값이 없으면 기본값을 반환합니다. (ko)
// firstHeaderValue returns the first value for a header key in map[string][]string,
// or the provided default if the key is missing or empty. (en)
func firstHeaderValue(hdr map[string][]string, key, def string) string {
if hdr == nil {
return def
}
if vs, ok := hdr[key]; ok && len(vs) > 0 {
return vs[0]
}
return def
}
var (
@@ -265,8 +422,8 @@ var hopGateOwnedHeaders = map[string]struct{}{
"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)
// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko)
// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/525). (en)
//
// 템플릿 로딩 우선순위: (ko)
// 1. HOP_ERROR_PAGES_DIR/<status>.html (또는 ./errors/<status>.html) (ko)
@@ -282,31 +439,9 @@ func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) {
setSecurityAndIdentityHeaders(w, r)
}
// 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)
// Delegates actual HTML rendering to internal/errorpages. (en)
// 실제 HTML 렌더링은 internal/errorpages 패키지에 위임합니다. (ko)
errorpages.Render(w, r, status)
}
// setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko)
@@ -640,10 +775,8 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand
func main() {
logger := logging.NewStdJSONLogger("server")
// Prometheus 메트릭 등록
observability.MustRegister()
// 1. 서버 설정 로드 (.env + 환경변수)
// internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다.
cfg, err := config.LoadServerConfigFromEnv()
if err != nil {
logger.Error("failed to load server config from env", logging.Fields{
@@ -652,6 +785,34 @@ func main() {
os.Exit(1)
}
// 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선)
httpListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTP_LISTEN")
httpsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTPS_LISTEN")
dtlsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_DTLS_LISTEN")
domainEnv := getEnvOrPanic(logger, "HOP_SERVER_DOMAIN")
debugEnv := getEnvOrPanic(logger, "HOP_SERVER_DEBUG")
// 디버깅 플래그 형식 확인
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_DTLS_LISTEN": dtlsListenEnv,
"HOP_SERVER_DOMAIN": domainEnv,
"HOP_SERVER_DEBUG": debugEnv,
})
// Prometheus 메트릭 등록
observability.MustRegister()
logger.Info("hop-gate server starting", logging.Fields{
"stack": "prometheus-loki-grafana",
"http_listen": cfg.HTTPListen,

2
go.sum
View File

@@ -32,6 +32,8 @@ 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/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View File

@@ -107,9 +107,13 @@ func loadDotEnvOnce() {
val = strings.Trim(val, `"'`)
if key != "" {
// 이미 OS 환경변수에 설정된 값이 있는 경우 이를 우선시하고,
// 비어 있는 키에 대해서만 .env 값을 주입합니다.
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
}
if err := scanner.Err(); err != nil {
dotenvErr = err
return
@@ -209,7 +213,8 @@ func loadLoggingFromEnv() LoggingConfig {
}
}
// LoadServerConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 서버 설정을 구성합니다.
// LoadServerConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤
// "환경변수 > .env" 우선순위로 서버 설정을 구성합니다.
func LoadServerConfigFromEnv() (*ServerConfig, error) {
loadDotEnvOnce()
if dotenvErr != nil {
@@ -228,7 +233,8 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) {
return cfg, nil
}
// LoadClientConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 클라이언트 설정을 구성합니다.
// LoadClientConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤
// "환경변수 > .env" 우선순위로 클라이언트 설정을 구성합니다.
// 실제 런타임에서 사용되는 필드는 ServerAddr, Domain, ClientAPIKey, LocalTarget 입니다.
func LoadClientConfigFromEnv() (*ClientConfig, error) {
loadDotEnvOnce()

View File

@@ -57,6 +57,24 @@ func (protobufCodec) Encode(w io.Writer, env *Envelope) error {
if err != nil {
return err
}
// Body/stream payload 하드 리밋: 4KiB (StreamChunkSize).
// HTTP 단일 Envelope 및 스트림 기반 프레임 모두에서 payload 가 이 값을 넘지 않도록 강제합니다.
// Enforce a 4KiB hard limit (StreamChunkSize) for HTTP bodies and stream payloads.
switch env.Type {
case MessageTypeHTTP:
if env.HTTPRequest != nil && len(env.HTTPRequest.Body) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: http request body too large: %d bytes (max %d)", len(env.HTTPRequest.Body), StreamChunkSize)
}
if env.HTTPResponse != nil && len(env.HTTPResponse.Body) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: http response body too large: %d bytes (max %d)", len(env.HTTPResponse.Body), StreamChunkSize)
}
case MessageTypeStreamData:
if env.StreamData != nil && len(env.StreamData.Data) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: stream data payload too large: %d bytes (max %d)", len(env.StreamData.Data), StreamChunkSize)
}
}
data, err := proto.Marshal(pbEnv)
if err != nil {
return fmt.Errorf("protobuf marshal envelope: %w", err)
@@ -114,8 +132,7 @@ func (protobufCodec) Decode(r io.Reader, env *Envelope) error {
var DefaultCodec WireCodec = protobufCodec{}
// toProtoEnvelope 는 내부 Envelope 구조체를 Protobuf Envelope 로 변환합니다.
// 현재 구현은 MessageTypeHTTP (HTTPRequest/HTTPResponse) 만 지원하며,
// 스트림 관련 타입은 이후 스트림 터널링 구현 단계에서 확장합니다.
// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다.
func toProtoEnvelope(env *Envelope) (*protocolpb.Envelope, error) {
switch env.Type {
case MessageTypeHTTP:
@@ -164,15 +181,80 @@ func toProtoEnvelope(env *Envelope) (*protocolpb.Envelope, error) {
}, nil
}
return nil, fmt.Errorf("protobuf codec: http envelope has neither request nor response")
case MessageTypeStreamOpen:
if env.StreamOpen == nil {
return nil, fmt.Errorf("protobuf codec: stream_open envelope missing payload")
}
so := env.StreamOpen
pbSO := &protocolpb.StreamOpen{
Id: string(so.ID),
ServiceName: so.Service,
TargetAddr: so.TargetAddr,
Header: make(map[string]*protocolpb.HeaderValues, len(so.Header)),
}
for k, vs := range so.Header {
hv := &protocolpb.HeaderValues{
Values: append([]string(nil), vs...),
}
pbSO.Header[k] = hv
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: pbSO,
},
}, nil
case MessageTypeStreamData:
if env.StreamData == nil {
return nil, fmt.Errorf("protobuf codec: stream_data envelope missing payload")
}
sd := env.StreamData
pbSD := &protocolpb.StreamData{
Id: string(sd.ID),
Seq: sd.Seq,
Data: sd.Data,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: pbSD,
},
}, nil
case MessageTypeStreamClose:
if env.StreamClose == nil {
return nil, fmt.Errorf("protobuf codec: stream_close envelope missing payload")
}
sc := env.StreamClose
pbSC := &protocolpb.StreamClose{
Id: string(sc.ID),
Error: sc.Error,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: pbSC,
},
}, nil
case MessageTypeStreamAck:
if env.StreamAck == nil {
return nil, fmt.Errorf("protobuf codec: stream_ack envelope missing payload")
}
sa := env.StreamAck
pbSA := &protocolpb.StreamAck{
Id: string(sa.ID),
AckSeq: sa.AckSeq,
LostSeqs: append([]uint64(nil), sa.LostSeqs...),
WindowSize: sa.WindowSize,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamAck{
StreamAck: pbSA,
},
}, nil
default:
// 스트림 관련 타입은 아직 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 요청/응답 지원합니다.
// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다.
func fromProtoEnvelope(pbEnv *protocolpb.Envelope, env *Envelope) error {
switch payload := pbEnv.Payload.(type) {
case *protocolpb.Envelope_HttpRequest:
@@ -198,6 +280,10 @@ func fromProtoEnvelope(pbEnv *protocolpb.Envelope, env *Envelope) error {
Body: append([]byte(nil), req.Body...),
}
env.HTTPResponse = nil
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
return nil
case *protocolpb.Envelope_HttpResponse:
@@ -221,6 +307,90 @@ func fromProtoEnvelope(pbEnv *protocolpb.Envelope, env *Envelope) error {
Error: resp.Error,
}
env.HTTPRequest = nil
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
return nil
case *protocolpb.Envelope_StreamOpen:
so := payload.StreamOpen
if so == nil {
return fmt.Errorf("protobuf codec: stream_open payload is nil")
}
hdr := make(map[string][]string, len(so.Header))
for k, hv := range so.Header {
if hv == nil {
continue
}
hdr[k] = append([]string(nil), hv.Values...)
}
env.Type = MessageTypeStreamOpen
env.StreamOpen = &StreamOpen{
ID: StreamID(so.Id),
Service: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: hdr,
}
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamData:
sd := payload.StreamData
if sd == nil {
return fmt.Errorf("protobuf codec: stream_data payload is nil")
}
env.Type = MessageTypeStreamData
env.StreamData = &StreamData{
ID: StreamID(sd.Id),
Seq: sd.Seq,
Data: append([]byte(nil), sd.Data...),
}
env.StreamOpen = nil
env.StreamClose = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamClose:
sc := payload.StreamClose
if sc == nil {
return fmt.Errorf("protobuf codec: stream_close payload is nil")
}
env.Type = MessageTypeStreamClose
env.StreamClose = &StreamClose{
ID: StreamID(sc.Id),
Error: sc.Error,
}
env.StreamOpen = nil
env.StreamData = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamAck:
sa := payload.StreamAck
if sa == nil {
return fmt.Errorf("protobuf codec: stream_ack payload is nil")
}
env.Type = MessageTypeStreamAck
env.StreamAck = &StreamAck{
ID: StreamID(sa.Id),
AckSeq: sa.AckSeq,
LostSeqs: append([]uint64(nil), sa.LostSeqs...),
WindowSize: sa.WindowSize,
}
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
default:

View File

@@ -32,6 +32,11 @@ type Response struct {
// MessageType 은 DTLS 위에서 교환되는 상위 레벨 메시지 종류를 나타냅니다.
type MessageType string
// StreamChunkSize 는 스트림 터널링 시 단일 StreamData 프레임에 담을 최대 payload 크기입니다.
// 현재 구현에서는 4KiB 로 고정하여 DTLS/UDP MTU 한계를 여유 있게 피하도록 합니다.
// StreamChunkSize is the maximum payload size per StreamData frame (4KiB).
const StreamChunkSize = 4 * 1024
const (
// MessageTypeHTTP 는 기존 단일 HTTP 요청/응답 메시지를 의미합니다.
// 이 경우 HTTPRequest / HTTPResponse 필드를 사용합니다.
@@ -76,6 +81,15 @@ type Envelope struct {
// StreamID 는 스트림(예: 특정 WebSocket 연결 또는 TCP 커넥션)을 구분하기 위한 식별자입니다.
type StreamID string
// HTTP-over-stream 터널링에서 사용되는 pseudo-header 키 상수입니다.
// These pseudo-header keys are used when tunneling HTTP over the stream protocol.
const (
HeaderKeyMethod = "X-HopGate-Method"
HeaderKeyURL = "X-HopGate-URL"
HeaderKeyHost = "X-HopGate-Host"
HeaderKeyStatus = "X-HopGate-Status"
)
// StreamOpen 은 새로운 스트림을 여는 요청을 나타냅니다.
type StreamOpen struct {
ID StreamID `json:"id"`

View File

@@ -8,6 +8,9 @@ import (
"net"
"net/http"
"net/url"
"sort"
"strconv"
"sync"
"time"
"github.com/dalbodeule/hop-gate/internal/dtls"
@@ -21,6 +24,9 @@ type ClientProxy struct {
HTTPClient *http.Client
Logger logging.Logger
LocalTarget string // e.g. "127.0.0.1:8080"
sendersMu sync.Mutex
streamSenders map[protocol.StreamID]*streamSender
}
// NewClientProxy 는 기본 HTTP 클라이언트 및 로거를 사용해 ClientProxy 를 생성합니다. (ko)
@@ -47,13 +53,97 @@ func NewClientProxy(logger logging.Logger, localTarget string) *ClientProxy {
},
Logger: logger.With(logging.Fields{"component": "client_proxy"}),
LocalTarget: localTarget,
streamSenders: make(map[protocol.StreamID]*streamSender),
}
}
// StartLoop 는 DTLS 세션에서 protocol.Envelope 를 읽고, HTTP 요청의 경우 로컬 HTTP 요청을 수행한 뒤
// protocol.Envelope(HTTP 응답 포함)을 다시 세션으로 쓰는 루프를 실행합니다. (ko)
// StartLoop reads protocol.Envelope messages from the DTLS session; for HTTP messages it
// performs local HTTP requests and writes back HTTP responses wrapped in an Envelope. (en)
// StartLoop 는 DTLS 세션에서 protocol.Envelope 를 읽고, HTTP/스트림 요청의 경우 로컬 HTTP 요청을 수행한 뒤
// protocol.Envelope(HTTP/스트림 응답 포함)을 다시 세션으로 쓰는 루프를 실행합니다. (ko)
// StartLoop reads protocol.Envelope messages from the DTLS session; for HTTP/stream
// messages it performs local HTTP requests and writes back responses over the DTLS
// tunnel. (en)
type streamSender struct {
mu sync.Mutex
outstanding map[uint64][]byte
}
func newStreamSender() *streamSender {
return &streamSender{
outstanding: make(map[uint64][]byte),
}
}
func (s *streamSender) register(seq uint64, data []byte) {
s.mu.Lock()
defer s.mu.Unlock()
if s.outstanding == nil {
s.outstanding = make(map[uint64][]byte)
}
buf := make([]byte, len(data))
copy(buf, data)
s.outstanding[seq] = buf
}
func (s *streamSender) handleAck(ack *protocol.StreamAck) map[uint64][]byte {
s.mu.Lock()
defer s.mu.Unlock()
if s.outstanding == nil {
return nil
}
// 연속 수신 완료 구간(seq <= AckSeq)은 outstanding 에서 제거합니다.
for seq := range s.outstanding {
if seq <= ack.AckSeq {
delete(s.outstanding, seq)
}
}
// LostSeqs 가 비어 있으면 재전송할 것이 없습니다.
if len(ack.LostSeqs) == 0 {
return nil
}
// LostSeqs 에 포함된 시퀀스 중, 아직 outstanding 에 남아 있는 것들만 재전송 대상으로 선택합니다.
lost := make(map[uint64][]byte, len(ack.LostSeqs))
for _, seq := range ack.LostSeqs {
if data, ok := s.outstanding[seq]; ok {
buf := make([]byte, len(data))
copy(buf, data)
lost[seq] = buf
}
}
return lost
}
func (p *ClientProxy) registerStreamSender(id protocol.StreamID, sender *streamSender) {
p.sendersMu.Lock()
defer p.sendersMu.Unlock()
if p.streamSenders == nil {
p.streamSenders = make(map[protocol.StreamID]*streamSender)
}
p.streamSenders[id] = sender
}
func (p *ClientProxy) unregisterStreamSender(id protocol.StreamID) {
p.sendersMu.Lock()
defer p.sendersMu.Unlock()
if p.streamSenders == nil {
return
}
delete(p.streamSenders, id)
}
func (p *ClientProxy) getStreamSender(id protocol.StreamID) *streamSender {
p.sendersMu.Lock()
defer p.sendersMu.Unlock()
if p.streamSenders == nil {
return nil
}
return p.streamSenders[id]
}
func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
if ctx == nil {
ctx = context.Background()
@@ -92,17 +182,79 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
return err
}
// 현재는 HTTP 타입만 지원하며, 그 외 타입은 에러로 처리합니다.
if env.Type != protocol.MessageTypeHTTP || env.HTTPRequest == nil {
switch env.Type {
case protocol.MessageTypeHTTP:
if err := p.handleHTTPEnvelope(ctx, sess, &env); err != nil {
log.Error("failed to handle http envelope", logging.Fields{
"error": err.Error(),
})
return err
}
case protocol.MessageTypeStreamOpen:
if err := p.handleStreamRequest(ctx, sess, &env); err != nil {
log.Error("failed to handle stream http envelope", logging.Fields{
"error": err.Error(),
})
return err
}
case protocol.MessageTypeStreamAck:
sa := env.StreamAck
if sa == nil {
log.Error("received stream_ack envelope with nil payload", nil)
return fmt.Errorf("stream_ack payload is nil")
}
streamID := protocol.StreamID(sa.ID)
sender := p.getStreamSender(streamID)
if sender == nil {
log.Warn("received stream_ack for unknown stream", logging.Fields{
"stream_id": sa.ID,
})
continue
}
lost := sender.handleAck(sa)
// LostSeqs 를 기반으로 선택적 재전송 수행
for seq, data := range lost {
retryEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamData,
StreamData: &protocol.StreamData{
ID: streamID,
Seq: seq,
Data: data,
},
}
if err := codec.Encode(sess, &retryEnv); err != nil {
log.Error("failed to retransmit stream_data after stream_ack", logging.Fields{
"stream_id": streamID,
"seq": seq,
"error": err.Error(),
})
return err
}
log.Info("retransmitted stream_data after stream_ack", logging.Fields{
"stream_id": streamID,
"seq": seq,
})
}
default:
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)
return fmt.Errorf("unsupported envelope type %q", env.Type)
}
}
}
// handleHTTPEnvelope 는 기존 단일 HTTP 요청/응답 Envelope 경로를 처리합니다. (ko)
// handleHTTPEnvelope handles the legacy single HTTP request/response envelope path. (en)
func (p *ClientProxy) handleHTTPEnvelope(ctx context.Context, sess dtls.Session, env *protocol.Envelope) error {
if env.HTTPRequest == nil {
return fmt.Errorf("http envelope missing http_request payload")
}
req := env.HTTPRequest
log := p.Logger
start := time.Now()
logReq := log.With(logging.Fields{
"request_id": req.RequestID,
"service": req.ServiceName,
@@ -133,7 +285,7 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
HTTPResponse: &resp,
}
if err := codec.Encode(sess, &respEnv); err != nil {
if err := protocol.DefaultCodec.Encode(sess, &respEnv); err != nil {
logReq.Error("failed to encode http response envelope", logging.Fields{
"error": err.Error(),
})
@@ -145,7 +297,370 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
"elapsed_ms": time.Since(start).Milliseconds(),
"error": resp.Error,
})
return nil
}
// handleStreamRequest 는 StreamOpen/StreamData/StreamClose 기반 HTTP 요청/응답 스트림을 처리합니다. (ko)
// handleStreamRequest handles an HTTP request/response exchange using StreamOpen/StreamData/StreamClose frames. (en)
func (p *ClientProxy) handleStreamRequest(ctx context.Context, sess dtls.Session, openEnv *protocol.Envelope) error {
codec := protocol.DefaultCodec
log := p.Logger
so := openEnv.StreamOpen
if so == nil {
return fmt.Errorf("stream_open envelope missing payload")
}
streamID := so.ID
// 이 스트림에 대한 송신 측 ARQ 상태를 준비하고, StartLoop 에서 들어오는 StreamAck 와 연동합니다.
sender := newStreamSender()
p.registerStreamSender(streamID, sender)
defer p.unregisterStreamSender(streamID)
// Pseudo-header 에서 HTTP 메타데이터를 추출합니다. (ko)
// Extract HTTP metadata from pseudo-headers. (en)
method := firstHeaderValue(so.Header, protocol.HeaderKeyMethod, http.MethodGet)
urlStr := firstHeaderValue(so.Header, protocol.HeaderKeyURL, "/")
_ = firstHeaderValue(so.Header, protocol.HeaderKeyHost, "")
if p.LocalTarget == "" {
return fmt.Errorf("local target is empty")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("parse url from stream_open: %w", err)
}
u.Scheme = "http"
u.Host = p.LocalTarget
// 로컬 HTTP 요청용 헤더 맵을 생성하면서 pseudo-header 는 제거합니다. (ko)
// Build local HTTP header map while stripping pseudo-headers. (en)
httpHeader := make(http.Header, len(so.Header))
for k, vs := range so.Header {
if k == protocol.HeaderKeyMethod ||
k == protocol.HeaderKeyURL ||
k == protocol.HeaderKeyHost ||
k == protocol.HeaderKeyStatus {
continue
}
for _, v := range vs {
httpHeader.Add(k, v)
}
}
// 요청 바디를 StreamData/StreamClose 프레임에서 모두 읽어 메모리에 적재합니다. (ko)
// Read the entire request body from StreamData/StreamClose frames into memory. (en)
//
// 동시에 수신 측 ARQ 상태( expectedSeq / out-of-order 버퍼 / LostSeqs )를 관리하고
// StreamAck 를 전송해 선택적 재전송(Selective Retransmission)을 유도합니다.
var (
bodyBuf bytes.Buffer
expectedSeq uint64
received = make(map[uint64][]byte)
lost = make(map[uint64]struct{})
)
const maxLostReport = 32
for {
var env protocol.Envelope
if err := codec.Decode(sess, &env); err != nil {
if err == io.EOF {
return fmt.Errorf("unexpected EOF while reading stream request body")
}
return fmt.Errorf("decode stream request frame: %w", err)
}
switch env.Type {
case protocol.MessageTypeStreamData:
sd := env.StreamData
if sd == nil {
return fmt.Errorf("stream_data payload is nil")
}
if sd.ID != streamID {
return fmt.Errorf("stream_data for unexpected stream id %q (expected %q)", sd.ID, streamID)
}
// 수신 측 ARQ: Seq 에 따라 분기
switch {
case sd.Seq == expectedSeq:
// 기대하던 순서의 프레임: 바로 bodyBuf 에 기록하고, 이후 버퍼된 연속 프레임도 flush.
if len(sd.Data) > 0 {
if _, err := bodyBuf.Write(sd.Data); err != nil {
return fmt.Errorf("buffer stream_data: %w", err)
}
}
expectedSeq++
for {
data, ok := received[expectedSeq]
if !ok {
break
}
if len(data) > 0 {
if _, err := bodyBuf.Write(data); err != nil {
return fmt.Errorf("buffer reordered stream_data: %w", err)
}
}
delete(received, expectedSeq)
delete(lost, expectedSeq)
expectedSeq++
}
// AckSeq 이전 구간의 lost 항목 정리
for seq := range lost {
if seq < expectedSeq {
delete(lost, seq)
}
}
case sd.Seq > expectedSeq:
// 앞선 일부 Seq 들이 누락된 상태: 현재 프레임을 버퍼링하고 missing seq 들을 lost 에 추가.
if len(sd.Data) > 0 {
buf := make([]byte, len(sd.Data))
copy(buf, sd.Data)
received[sd.Seq] = buf
}
for seq := expectedSeq; seq < sd.Seq && len(lost) < maxLostReport; seq++ {
if _, ok := lost[seq]; !ok {
lost[seq] = struct{}{}
}
}
default:
// sd.Seq < expectedSeq 인 경우: 이미 처리했거나 Ack 로 커버된 프레임 → 무시.
}
// 수신 측 StreamAck 전송:
// - AckSeq: 0부터 시작해 연속으로 수신 완료한 마지막 시퀀스 (expectedSeq-1)
// - LostSeqs: 현재 윈도우 내에서 누락된 시퀀스 중 상한 개수(maxLostReport)까지만 포함
var ackSeq uint64
if expectedSeq == 0 {
ackSeq = 0
} else {
ackSeq = expectedSeq - 1
}
lostSeqs := make([]uint64, 0, len(lost))
for seq := range lost {
if seq >= expectedSeq {
lostSeqs = append(lostSeqs, seq)
}
}
if len(lostSeqs) > 0 {
sort.Slice(lostSeqs, func(i, j int) bool { return lostSeqs[i] < lostSeqs[j] })
if len(lostSeqs) > maxLostReport {
lostSeqs = lostSeqs[:maxLostReport]
}
}
ackEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamAck,
StreamAck: &protocol.StreamAck{
ID: streamID,
AckSeq: ackSeq,
LostSeqs: lostSeqs,
},
}
if err := codec.Encode(sess, &ackEnv); err != nil {
return fmt.Errorf("send stream ack: %w", err)
}
case protocol.MessageTypeStreamClose:
sc := env.StreamClose
if sc == nil {
return fmt.Errorf("stream_close payload is nil")
}
if sc.ID != streamID {
return fmt.Errorf("stream_close for unexpected stream id %q (expected %q)", sc.ID, streamID)
}
// sc.Error 는 최소 구현에서는 로컬 요청 에러와 별도로 취급하지 않습니다. (ko)
// For the minimal implementation we do not surface sc.Error here. (en)
goto haveBody
default:
return fmt.Errorf("unexpected envelope type %q while reading stream request body", env.Type)
}
}
haveBody:
bodyBytes := bodyBuf.Bytes()
// 로컬 HTTP 요청 생성 (stream 기반 요청을 실제 HTTP 요청으로 변환). (ko)
// Build the local HTTP request from the stream-based metadata and body. (en)
req, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
if err != nil {
return fmt.Errorf("create http request from stream: %w", err)
}
if len(bodyBytes) > 0 {
buf := bytes.NewReader(bodyBytes)
req.Body = io.NopCloser(buf)
req.ContentLength = int64(len(bodyBytes))
}
req.Header = httpHeader
start := time.Now()
logReq := log.With(logging.Fields{
"request_id": string(streamID),
"service": so.Service,
"method": method,
"url": urlStr,
"stream_id": string(streamID),
"local_target": p.LocalTarget,
})
logReq.Info("received stream_open envelope from server", nil)
res, err := p.HTTPClient.Do(req)
if err != nil {
// 로컬 요청 실패 시, 502 + 에러 메시지를 스트림 응답으로 전송합니다. (ko)
// On local request failure, send a 502 response over the stream. (en)
errMsg := fmt.Sprintf("perform http request: %v", err)
streamRespHeader := map[string][]string{
"Content-Type": {"text/plain; charset=utf-8"},
protocol.HeaderKeyStatus: {strconv.Itoa(http.StatusBadGateway)},
}
respOpen := protocol.Envelope{
Type: protocol.MessageTypeStreamOpen,
StreamOpen: &protocol.StreamOpen{
ID: streamID,
Service: so.Service,
TargetAddr: so.TargetAddr,
Header: streamRespHeader,
},
}
if err2 := codec.Encode(sess, &respOpen); err2 != nil {
logReq.Error("failed to encode stream response open envelope (error path)", logging.Fields{
"error": err2.Error(),
})
return err2
}
dataEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamData,
StreamData: &protocol.StreamData{
ID: streamID,
Seq: 0,
Data: []byte("HopGate: " + errMsg),
},
}
// 에러 응답 프레임도 ARQ 대상에 등록합니다.
sender.register(0, dataEnv.StreamData.Data)
if err2 := codec.Encode(sess, &dataEnv); err2 != nil {
logReq.Error("failed to encode stream response data envelope (error path)", logging.Fields{
"error": err2.Error(),
})
return err2
}
closeEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamClose,
StreamClose: &protocol.StreamClose{
ID: streamID,
Error: errMsg,
},
}
if err2 := codec.Encode(sess, &closeEnv); err2 != nil {
logReq.Error("failed to encode stream response close envelope (error path)", logging.Fields{
"error": err2.Error(),
})
return err2
}
logReq.Error("local http request failed (stream)", logging.Fields{
"error": err.Error(),
})
return nil
}
defer res.Body.Close()
// 응답을 StreamOpen + StreamData(4KiB chunk) + StreamClose 프레임으로 전송합니다. (ko)
// Send the response as StreamOpen + StreamData (4KiB chunks) + StreamClose frames. (en)
// 응답 헤더 맵을 복사하고 상태 코드를 pseudo-header 로 추가합니다. (ko)
// Copy response headers and attach status code as a pseudo-header. (en)
streamRespHeader := make(map[string][]string, len(res.Header)+1)
for k, vs := range res.Header {
streamRespHeader[k] = append([]string(nil), vs...)
}
statusCode := res.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
streamRespHeader[protocol.HeaderKeyStatus] = []string{strconv.Itoa(statusCode)}
respOpen := protocol.Envelope{
Type: protocol.MessageTypeStreamOpen,
StreamOpen: &protocol.StreamOpen{
ID: streamID,
Service: so.Service,
TargetAddr: so.TargetAddr,
Header: streamRespHeader,
},
}
if err := codec.Encode(sess, &respOpen); err != nil {
logReq.Error("failed to encode stream response open envelope", logging.Fields{
"error": err.Error(),
})
return err
}
// 응답 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko)
// Chunk the response body into 4KiB (StreamChunkSize) StreamData frames. (en)
var seq uint64
chunk := make([]byte, protocol.StreamChunkSize)
for {
n, err := res.Body.Read(chunk)
if n > 0 {
dataCopy := append([]byte(nil), chunk[:n]...)
// 송신 측 ARQ: Seq 별 payload 를 기록해 두었다가, StreamAck 의 LostSeqs 를 기반으로 재전송할 수 있습니다.
sender.register(seq, dataCopy)
dataEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamData,
StreamData: &protocol.StreamData{
ID: streamID,
Seq: seq,
Data: dataCopy,
},
}
if err2 := codec.Encode(sess, &dataEnv); err2 != nil {
logReq.Error("failed to encode stream response data envelope", logging.Fields{
"error": err2.Error(),
})
return err2
}
seq++
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read http response body for streaming: %w", err)
}
}
closeEnv := protocol.Envelope{
Type: protocol.MessageTypeStreamClose,
StreamClose: &protocol.StreamClose{
ID: streamID,
Error: "",
},
}
if err := codec.Encode(sess, &closeEnv); err != nil {
logReq.Error("failed to encode stream response close envelope", logging.Fields{
"error": err.Error(),
})
return err
}
logReq.Info("stream http response sent to server", logging.Fields{
"status": statusCode,
"elapsed_ms": time.Since(start).Milliseconds(),
"error": "",
})
return nil
}
// forwardToLocal 는 protocol.Request 를 로컬 HTTP 요청으로 변환하고 protocol.Response 를 채웁니다. (ko)
@@ -192,18 +707,25 @@ func (p *ClientProxy) forwardToLocal(ctx context.Context, preq *protocol.Request
}
// DTLS over UDP has an upper bound on packet size (~64KiB). 전체 HTTP 바디를
// 하나의 JSON Envelope 로 감싸 전송하는 현재 설계에서는 바디가 너무 크면
// 하나의 Envelope 로 감싸 전송하는 현재 설계에서는, 바디가 너무 크면
// OS 레벨에서 "message too long" (EMSGSIZE) 가 발생할 수 있습니다. (ko)
//
// 이를 피하기 위해, 터널링 가능한 바디 크기에 상한을 두고, 이를 초과하는
// 응답은 502 Bad Gateway + HopGate 전용 에러 메시지로 대체합니다. (ko)
// 이를 피하기 위해, 터널링 가능한 **단일 HTTP 바디** 크기에 상한을 두고,
// 이를 초과하는 응답은 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
// single-envelope design, very large bodies can still trigger "message too long"
// (EMSGSIZE) at the OS level. To avoid this, we cap the tunneled HTTP body size
// and replace oversized responses with a 502 Bad Gateway + HopGate-specific
// error body. (en)
//
// Protobuf 기반 터널링에서는 향후 StreamData(4KiB) 단위로 나누어 전송할 예정이지만,
// 그 전 단계에서도 body 자체를 4KiB( StreamChunkSize )로 하드 리밋하여
// Proto message body 필드가 지나치게 커지지 않도록 합니다. (ko)
//
// Even before full stream tunneling is implemented, we hard-limit the protobuf
// body field to 4KiB (StreamChunkSize) so that individual messages remain small. (en)
const maxTunnelBodyBytes = protocol.StreamChunkSize
limited := &io.LimitedReader{
R: res.Body,
@@ -233,3 +755,15 @@ func (p *ClientProxy) forwardToLocal(ctx context.Context, preq *protocol.Request
return nil
}
// firstHeaderValue 는 주어진 키의 첫 번째 헤더 값을 반환하고, 없으면 기본값을 반환합니다. (ko)
// firstHeaderValue returns the first header value for a key, or a default if absent. (en)
func firstHeaderValue(hdr map[string][]string, key, def string) string {
if hdr == nil {
return def
}
if vs, ok := hdr[key]; ok && len(vs) > 0 {
return vs[0]
}
return def
}

View File

@@ -11,8 +11,10 @@ This document tracks implementation progress against the HopGate architecture an
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.
- 기본 Proxy 동작(HTTP ↔ DTLS 터널링), Admin API 비즈니스 로직, ACME 기반 인증서 관리는 구현 완료된 상태.
Core proxying (HTTP ↔ DTLS tunneling), admin API business logic, and ACME-based certificate management are implemented.
- 스트림 ARQ, Observability, Hardening, ACME 고급 전략 등은 아직 남아 있는 다음 단계 작업이다.
Stream-level ARQ, observability, hardening, and advanced ACME operational strategies remain as next-step work items.
---
@@ -44,7 +46,7 @@ This document tracks implementation progress against the HopGate architecture an
- PostgreSQL 연결 및 ent 스키마 init (`store.OpenPostgresFromEnv`).
- Debug 모드 시 self-signed localhost cert 생성 (`dtls.NewSelfSignedLocalhostConfig`).
- DTLS 서버 생성 (`dtls.NewPionServer`) 및 Accept + Handshake 루프 (`PerformServerHandshake`).
- DummyDomainValidator 사용해 도메인/API Key 조합을 임시로 모두 허용.
- ent 기반 `DomainValidator` + `domainGateValidator` 사용해 `(domain, client_api_key)` 조합과 DNS/IP(옵션) 검증을 수행.
- 클라이언트 메인: [`cmd/client/main.go`](cmd/client/main.go)
- CLI + env 병합 설정 (우선순위: CLI > env).
@@ -121,7 +123,7 @@ This document tracks implementation progress against the HopGate architecture an
- `POST /api/v1/admin/domains/register`
- `POST /api/v1/admin/domains/unregister`
- JSON request/response 구조 정의 및 기본 에러 처리.
- 아직 실제 서비스/라우터 wiring, ent 기반 구현 미완성.
- 실제 서비스(`DomainService`) 및 라우터 wiring, ent 기반 구현이 완료되어 도메인 등록/해제가 동작.
---
@@ -224,10 +226,11 @@ This document tracks implementation progress against the HopGate architecture an
### 3.3 Proxy Core / HTTP Tunneling
- [x] 서버 측 Proxy 구현 확장: [`internal/proxy/server.go`](internal/proxy/server.go)
- HTTP/HTTPS 리스너와 DTLS 세션 매핑 구현.
- `Router` 구현체 추가 (도메인/패스 → 클라이언트/서비스).
- 요청/응답을 `internal/protocol` 구조체로 직렬화/역직렬화.
- [ ] 서버 측 Proxy 구현 확장: [`internal/proxy/server.go`](internal/proxy/server.go)
- 현재 `ServerProxy` / `Router` 인터페이스와 `NewHTTPServer` 만 정의되어 있고,
실제 HTTP/HTTPS 리스너와 DTLS 세션 매핑 로직은 [`cmd/server/main.go`](cmd/server/main.go) 의
`newHTTPHandler` / `dtlsSessionWrapper.ForwardHTTP` 안에 위치합니다.
- Proxy 코어 로직을 proxy 레이어로 이동하는 리팩터링은 아직 진행되지 않았습니다. (3.6 항목과 연동)
- [x] 클라이언트 측 Proxy 구현 확장: [`internal/proxy/client.go`](internal/proxy/client.go)
- DTLS 세션에서 `protocol.Request` 수신 → 로컬 HTTP 호출 → `protocol.Response` 전송 루프 구현.
@@ -242,11 +245,11 @@ This document tracks implementation progress against the HopGate architecture an
#### 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**.
초기 HTTP 터널링 설계는 **단일 JSON Envelope + 단일 DTLS 쓰기** 방식(요청/응답 바디 전체를 한 번에 전송)이었고,
대용량 응답 바디에서 UDP MTU 한계로 인한 `sendto: message too long` 문제가 발생할 수 있습니다.
이 한계를 제거하기 위해, 현재 코드는 DTLS 위 애플리케이션 프로토콜을 **스트림/프레임 기반**으로 재설계하여 `StreamOpen` / `StreamData` / `StreamClose` 를 사용합니다.
The initial tunneling model used a **single JSON envelope + single DTLS write per HTTP message**, which could hit UDP MTU limits (`sendto: message too long`) for large bodies.
The current implementation uses a **stream/frame-based** protocol over DTLS (`StreamOpen` / `StreamData` / `StreamClose`), and this section documents its constraints and further improvements (e.g. ARQ).
고려해야 할 제약 / Constraints:
@@ -299,62 +302,44 @@ The following tasks describe concrete work items to be implemented on the `featu
- [`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`.
- [x] MTU-safe chunk 크기 정의
- DTLS/UDP 헤더 및 Protobuf/length-prefix 오버헤드를 고려해 안전한 payload 크기(4KiB)를 상수로 정의합니다.
Define a safe payload size constant (4KiB) considering DTLS/UDP headers and Protobuf/length-prefix framing.
- 이 값은 [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32) 의 `StreamChunkSize` 로 정의되었습니다.
Implemented as `StreamChunkSize` in [`internal/protocol/protocol.go`](internal/protocol/protocol.go:32).
- 이후 HTTP 바디 스트림 터널링 구현 시, 모든 `StreamData.Data` 는 이 크기 이하 chunk 로 잘라 전송해야 합니다.
In the stream tunneling implementation, every `StreamData.Data` must be sliced into chunks no larger than this size.
---
##### 3.3A.2 애플리케이션 레벨 ARQ 설계 (Selective Retransmission)
##### 3.3A.2 Application-level ARQ (Selective Retransmission)
- [x] 수신 측 스트림 상태 관리 로직 설계
- 스트림별로 다음 상태를 유지합니다.
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] 수신 측 ARQ 상태 관리 구현
- 스트림별로 `expectedSeq`, out-of-order chunk 버퍼(`received`), 누락 시퀀스 집합(`lost`)을 유지하면서,
in-order / out-of-order 프레임을 구분해 HTTP 바디 버퍼에 순서대로 쌓습니다.
- For each stream, maintain `expectedSeq`, an out-of-order buffer (`received`), and a lost-sequence set (`lost`),
delivering in-order frames directly to the HTTP body buffer while buffering/reordering out-of-order ones.
- [x] 수신 측 StreamAck 전송 정책
- 주기적 타이머 또는 일정 수의 프레임 처리 후에 `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] 수신 측 StreamAck 전송 정책 구현
- 각 `StreamData` 수신 시점에 `AckSeq = expectedSeq - 1` 과 현재 윈도우에서 누락된 시퀀스 일부(`LostSeqs`, 상한 개수 적용)를 포함한
`StreamAck{AckSeq, LostSeqs}` 를 전송해 선택적 재전송을 유도합니다.
- On every `StreamData` frame, send `StreamAck{AckSeq, LostSeqs}` where `AckSeq = expectedSeq - 1` and `LostSeqs`
contains a bounded set (up to a fixed limit) of missing sequence numbers in the current receive window.
- [x] 송신 측 재전송 로직
- 스트림별로 다음 상태를 유지합니다.
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}` 전송.
- [x] 송신 측 재전송 로직 구현 (StreamAck 기반)
- 응답 스트림 송신 측에서 스트림별 `streamSender` 를 두고, `outstanding[seq] = payload` 로 아직 Ack 되지 않은 프레임을 추적합니다.
- `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.
- `seq <= AckSeq` 인 항목은 모두 제거하고,
- `LostSeqs` 에 포함된 시퀀스에 대해서만 `StreamData{ID, Seq, Data}` 를 재전송합니다.
- A per-stream `streamSender` tracks `outstanding[seq] = payload` for unacknowledged frames. Upon receiving
`StreamAck{AckSeq, LostSeqs}`, it deletes all `seq <= AckSeq` and retransmits only frames whose sequence
numbers appear in `LostSeqs`.
> Note: 현재 구현은 StreamAck 기반 **선택적 재전송(Selective Retransmission)** 까지 포함하며,
> 별도의 RTO(재전송 타이머) 기반 백그라운드 재전송 루프는 향후 확장 여지로 남겨둔 상태입니다.
> Note: The current implementation covers StreamAck-based **selective retransmission**; a separate RTO-based
> background retransmission loop is left as a potential future enhancement.
---
@@ -362,10 +347,8 @@ The following tasks describe concrete work items to be implemented on the `featu
##### 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:
- `ForwardHTTP` 는 스트림 기반 HTTP 요청/응답을 처리하도록 구현되어 있으며, 동작은 다음과 같습니다.
`ForwardHTTP` is implemented in stream mode and behaves as follows:
- HTTP 요청 수신 시:
- 새로운 `StreamID` 를 발급합니다 (세션별 증가).
Generate a new `StreamID` per incoming HTTP request on the DTLS session.
@@ -404,20 +387,19 @@ The following tasks describe concrete work items to be implemented on the `featu
##### 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)으로 전환
- [x] JSON 기반 스트림 프로토콜의 1단계 구현/안정화 이후, 직렬화 포맷 재검토 및 Protobuf 전환
- 현재는 JSON 대신 Protobuf length-prefix `Envelope` 포맷을 기본으로 사용합니다.
The runtime now uses a Protobuf-based, length-prefixed `Envelope` format instead of JSON.
- HTTP/스트림 payload 는 여전히 MTU-safe 크기(예: 4KiB, `StreamChunkSize`)로 제한되어 있어, 단일 프레임이 과도하게 커지지 않습니다.
HTTP/stream payloads remain bounded to an MTU-safe size (e.g. 4KiB via `StreamChunkSize`), so individual frames stay small.
- [x] length-prefix 이진 프레임(Protobuf)으로 전환
- 동일한 logical model (`StreamOpen` / `StreamData(seq)` / `StreamClose` / `StreamAck`)을 유지한 채,
wire-format Protobuf 또는 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.
wire-format Protobuf length-prefix binary 프레이밍으로 교체했고, 이는 `protobufCodec` 으로 구현되었습니다.
We now keep the same logical model while using Protobuf length-prefixed framing via `protobufCodec`.
- [x] 이 전환은 `internal/protocol` 내 직렬화 레이어를 얇은 abstraction 으로 감싸 구현습니다.
- [`internal/protocol/codec.go`](internal/protocol/codec.go:130) 에 `WireCodec` 인터페이스와 Protobuf 기반 `DefaultCodec` 을 도입,
호출자는 `protocol.DefaultCodec` 만 사용하고, JSON codec 은 보조 용도로만 남아 있습니다.
In [`internal/protocol/codec.go`](internal/protocol/codec.go:130), the `WireCodec` abstraction and Protobuf-based `DefaultCodec` allow callers to use only `protocol.DefaultCodec` while JSON remains as an auxiliary codec.
---
@@ -440,10 +422,10 @@ The following tasks describe concrete work items to be implemented on the `featu
### 3.5 Observability / 관측성
- [ ] Prometheus 메트릭 노출 및 서버 wiring
- [x] Prometheus 메트릭 노출 및 서버 wiring
- `cmd/server/main.go` 에 Prometheus `/metrics` 엔드포인트 추가 (예: promhttp.Handler).
- DTLS 세션 수, DTLS 핸드셰이크 성공/실패 수, HTTP/Proxy 요청 수 및 에러 수에 대한 카운터/게이지 메트릭 정의.
- 도메인, 클라이언트 ID, request_id 등의 라벨 설계 및 현재 구조적 로 필드와 일관성 유지.
- DTLS 핸드셰이크 성공/실패 수, HTTP 요청 수, HTTP 요청 지연, Proxy 에러 수에 대한 메트릭 정의합니다.
- 메트릭 라벨은 메서드/상태 코드/결과/에러 타입 등에 한정되며, 도메인/클라이언트 ID/request_id 구조적 로 필드로만 노출됩니다.
- [ ] Loki/Grafana 대시보드 및 쿼리 예시
- Loki/Promtail 구성을 가정한 주요 로그 쿼리 예시 정리(도메인, 클라이언트 ID, request_id 기준).
@@ -480,8 +462,10 @@ The following tasks describe concrete work items to be implemented on the `featu
### Milestone 2 — Full HTTP Tunneling (프락시 동작 완성)
- [ ] 서버 Proxy 코어 구현 및 HTTPS ↔ DTLS 라우팅.
- [ ] 클라이언트 Proxy 루프 구현 및 로컬 서비스 연동.
- [x] 서버 Proxy 코어 구현 및 HTTPS ↔ DTLS 라우팅.
- 현재 `cmd/server/main.go` 의 `newHTTPHandler` / `dtlsSessionWrapper.ForwardHTTP` 경로에서 동작합니다.
- [x] 클라이언트 Proxy 루프 구현 및 로컬 서비스 연동.
- `cmd/client/main.go` + [`ClientProxy.StartLoop()`](internal/proxy/client.go:59) 를 통해 DTLS 세션 위에서 로컬 서비스와 연동됩니다.
- [ ] End-to-end HTTP 요청/응답 터널링 E2E 테스트.
### Milestone 3 — ACME + TLS/DTLS 정식 인증
@@ -493,6 +477,9 @@ The following tasks describe concrete work items to be implemented on the `featu
### Milestone 4 — Observability & Hardening
- [ ] Prometheus/Loki/Grafana 통합.
- Prometheus 메트릭 정의 및 `/metrics` 엔드포인트는 이미 구현 및 동작 중이며,
Loki/Promtail/Grafana 대시보드 및 운영 통합 작업은 아직 남아 있습니다.
- [ ] 에러/리트라이/타임아웃 정책 정교화.
- [ ] 보안/구성 최종 점검 및 문서화.