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)
}

View File

@@ -0,0 +1,69 @@
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
}

View File

@@ -0,0 +1,194 @@
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)
}
dtlsCfg := &piondtls.Config{
Certificates: cfg.TLSConfig.Certificates,
InsecureSkipVerify: cfg.TLSConfig.InsecureSkipVerify,
// 필요 시 RootCAs, ClientAuth, 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{
Certificates: c.tlsConfig.Certificates,
InsecureSkipVerify: c.tlsConfig.InsecureSkipVerify,
// 필요 시 ServerName, RootCAs 등 추가 설정
}
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
}

View File

@@ -0,0 +1,33 @@
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:]
}