feat(dtls): add dtls client-server handshake flow

Implement initial DTLS handshake flow for server and client using
pion/dtls. Load server and client configuration from .env/environment,
including new debug flags and logging config.

On the server:
- load ServerConfig from env, including DTLS listen addr and debug flag
- create DTLS listener with optional self-signed localhost cert in debug
- accept DTLS sessions and run PerformServerHandshake with a dummy
  domain validator

On the client:
- load ClientConfig from env, then override with CLI flags where given
- validate required fields: server_addr, domain, api_key, local_target
- create DTLS client and run PerformClientHandshake
- support debug mode to skip server certificate verification

Also:
- update go.mod/go.sum with pion/dtls and related dependencies
- extend .env.example with new ports, client config, and debug flags
- ignore built binaries via bin/ in .gitignore

BREAKING CHANGE: client environment variables have changed. The former
HOP_CLIENT_ID, HOP_CLIENT_AUTH_TOKEN and HOP_CLIENT_SERVICE_PORTS are
replaced by HOP_CLIENT_DOMAIN, HOP_CLIENT_API_KEY,
HOP_CLIENT_LOCAL_TARGET and HOP_CLIENT_DEBUG. Client startup now
requires server_addr, domain, api_key and local_target to be provided
(via env or CLI).
This commit is contained in:
dalbodeule
2025-11-26 17:04:45 +09:00
parent 4d5b7f15f3
commit 2121b56511
11 changed files with 778 additions and 31 deletions

196
internal/dtls/handshake.go Normal file
View File

@@ -0,0 +1,196 @@
package dtls
import (
"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
if err := json.NewDecoder(sess).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
if err := json.NewDecoder(sess).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)
}