diff --git a/.env.example b/.env.example index a319fbf..178ebe7 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,13 @@ HOP_LOKI_ENABLE=false # ---- Server ports & domains ---- # HTTP 리스닝 포트 (보통 :80, ACME HTTP-01 및 HTTPS 리다이렉트용) -HOP_SERVER_HTTP_LISTEN=:80 +HOP_SERVER_HTTP_LISTEN=:8080 # HTTPS 리스닝 포트 (보통 :443) -HOP_SERVER_HTTPS_LISTEN=:443 +HOP_SERVER_HTTPS_LISTEN=:8443 # DTLS 리스닝 포트 (보통 :443, 필요시 별도 포트 사용) -HOP_SERVER_DTLS_LISTEN=:443 +HOP_SERVER_DTLS_LISTEN=:8443 # 메인 도메인 (예: example.com) HOP_SERVER_DOMAIN=example.com @@ -45,19 +45,25 @@ HOP_SERVER_DOMAIN=example.com # 예: api.example.com,edge.example.com HOP_SERVER_PROXY_DOMAINS=api.example.com,edge.example.com +# 디버깅용 플래그 +# 1. self signed localhost cert 사용여부 (debug: true -> 사용) +HOP_SERVER_DEBUG=true # ---- Client settings ---- # DTLS 서버 주소 (host:port) # 예: example.com:443 -HOP_CLIENT_SERVER_ADDR=example.com:443 +HOP_CLIENT_SERVER_ADDR=localhost:8443 -# 클라이언트 식별자 -HOP_CLIENT_ID=client-1 +# 클라이언트 도메인 +HOP_CLIENT_DOMAIN=test.example.com -# 선택적 인증 토큰 (서버에서 검증용으로 사용 가능) -HOP_CLIENT_AUTH_TOKEN= +# 인증 토큰 (서버에서 검증용으로 사용 가능) +HOP_CLIENT_API_KEY=TEST_API_KEY_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -# 서비스 매핑: name=host:port 형태, 콤마 구분 -# 예: web=127.0.0.1:8080,admin=127.0.0.1:9000 -HOP_CLIENT_SERVICE_PORTS=web=127.0.0.1:8080,admin=127.0.0.1:9000 \ No newline at end of file +# 서비스 매핑: name=host:port 형태 +HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080 + +# 디버깅용 플래그 +# 1. self signed 인증서를 신뢰(인증서 체인 검증 스킵) +HOP_CLIENT_DEBUG=true \ No newline at end of file diff --git a/.gitignore b/.gitignore index fab0920..e6c535b 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,6 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/go,goland+all,dotenv,macos,linux,windows + +# builded binary +bin/ \ No newline at end of file diff --git a/cmd/client/main.go b/cmd/client/main.go index add3ad5..e36b2ef 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,16 +1,134 @@ package main import ( + "context" + "crypto/tls" + "flag" + "os" + "strings" + + "github.com/dalbodeule/hop-gate/internal/config" + "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/logging" ) +// maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다. +func maskAPIKey(key string) string { + if len(key) <= 8 { + return "***" + } + return key[:4] + "..." + key[len(key)-4:] +} + +// firstNonEmpty 는 앞에서부터 처음으로 non-empty 인 문자열을 반환합니다. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + 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 포함)에서 클라이언트 설정 로드 + envCfg, err := config.LoadClientConfigFromEnv() + if err != nil { + logger.Error("failed to load client config from env", logging.Fields{ + "error": err.Error(), + }) + os.Exit(1) + } + + // 2. CLI 인자 우선, env 후순위로 최종 설정 구성 + finalCfg := &config.ClientConfig{ + ServerAddr: firstNonEmpty(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(envCfg.ServerAddr)), + Domain: firstNonEmpty(strings.TrimSpace(*domainFlag), strings.TrimSpace(envCfg.Domain)), + ClientAPIKey: firstNonEmpty(strings.TrimSpace(*apiKeyFlag), strings.TrimSpace(envCfg.ClientAPIKey)), + LocalTarget: firstNonEmpty(strings.TrimSpace(*localTargetFlag), strings.TrimSpace(envCfg.LocalTarget)), + Debug: envCfg.Debug, + Logging: envCfg.Logging, + } + + // 3. 필수 필드 검증 + missing := []string{} + if finalCfg.ServerAddr == "" { + missing = append(missing, "server_addr") + } + 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) + } + logger.Info("hop-gate client starting", logging.Fields{ - "stack": "prometheus-loki-grafana", + "stack": "prometheus-loki-grafana", + "server_addr": finalCfg.ServerAddr, + "domain": finalCfg.Domain, + "local_target": finalCfg.LocalTarget, + "client_api_key_masked": maskAPIKey(finalCfg.ClientAPIKey), + "debug": finalCfg.Debug, + }) + + // 4. DTLS 클라이언트 연결 및 핸드셰이크 + ctx := context.Background() + + // 디버그 모드에서는 서버 인증서 검증을 스킵(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, + } + } + + 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) + } + 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, }) - // TODO: load configuration from internal/config - // TODO: initialize logging details (instance, env, version) via logger.With(...) - // TODO: establish DTLS connection to server via internal/dtls - // TODO: start request handling loop using internal/proxy and internal/protocol } diff --git a/cmd/server/main.go b/cmd/server/main.go index c7d6e5d..0eddd59 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,16 +1,117 @@ package main import ( + "context" + "crypto/tls" + "os" + + "github.com/dalbodeule/hop-gate/internal/config" + "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/logging" ) func main() { logger := logging.NewStdJSONLogger("server") + + // 1. 서버 설정 로드 (.env + 환경변수) + cfg, err := config.LoadServerConfigFromEnv() + if err != nil { + logger.Error("failed to load server config from env", logging.Fields{ + "error": err.Error(), + }) + os.Exit(1) + } + logger.Info("hop-gate server starting", logging.Fields{ - "stack": "prometheus-loki-grafana", + "stack": "prometheus-loki-grafana", + "http_listen": cfg.HTTPListen, + "https_listen": cfg.HTTPSListen, + "dtls_listen": cfg.DTLSListen, + "domain": cfg.Domain, + "debug": cfg.Debug, }) - // TODO: load configuration from internal/config - // TODO: initialize logging details (instance, env, version) via logger.With(...) - // TODO: initialize ACME manager from internal/acme - // TODO: start HTTP/HTTPS listeners and DTLS listener + + // 2. DTLS 서버 리스너 생성 (pion/dtls 기반) + // + // Debug 모드일 때는 self-signed localhost 인증서를 사용해 테스트 할 수 있도록 + // internal/dtls.NewSelfSignedLocalhostConfig() 를 사용합니다. + // 운영 환경에서는 internal/acme.Manager 를 통해 얻은 tls.Config 를 + // PionServerConfig.TLSConfig 로 전달해야 합니다. + var tlsCfg *tls.Config + if cfg.Debug { + 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": "do not use this in production", + }) + } + + dtlsServer, err := dtls.NewPionServer(dtls.PionServerConfig{ + Addr: cfg.DTLSListen, + TLSConfig: tlsCfg, // debug 모드면 self-signed, 아니면 nil(기본값/ACME로 교체 예정) + }) + 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, + }) + + // 3. 도메인 검증기 준비 (현재는 Dummy 구현) + // + // DomainValidator 는 (domain, client_api_key) 조합을 검증합니다. + // 지금은 DummyDomainValidator 로 모두 허용하지만, + // 향후 ent + PostgreSQL 기반 구현으로 교체해야 합니다. + validator := dtls.DummyDomainValidator{ + Logger: logger, + } + + ctx := context.Background() + + // 4. DTLS Accept 루프 + Handshake + for { + sess, err := dtlsServer.Accept() + if err != nil { + logger.Error("dtls accept failed", logging.Fields{ + "error": err.Error(), + }) + continue + } + + // 각 세션별로 goroutine 에서 핸드셰이크 및 후속 처리를 수행합니다. + go func(s dtls.Session) { + defer s.Close() + + hsRes, err := dtls.PerformServerHandshake(ctx, s, validator, logger) + if err != nil { + // PerformServerHandshake 내부에서 이미 상세 로그를 남기므로 여기서는 요약만 기록합니다. + logger.Warn("dtls handshake failed", logging.Fields{ + "session_id": s.ID(), + "error": err.Error(), + }) + return + } + + // Handshake 성공: 서버 측은 어떤 도메인이 연결되었는지 알 수 있습니다. + logger.Info("dtls handshake completed", logging.Fields{ + "session_id": s.ID(), + "domain": hsRes.Domain, + }) + + // TODO: + // - hsRes.Domain 과 연결된 세션을 proxy 레이어에 등록 + // - HTTP 요청을 이 세션을 통해 해당 클라이언트로 라우팅 + // - 세션 생명주기/타임아웃 관리 등 + }(sess) + } } diff --git a/go.mod b/go.mod index a2d2862..d0e75a9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,13 @@ go 1.25.4 require ( entgo.io/ent v0.14.5 github.com/google/uuid v1.3.0 + github.com/pion/dtls/v3 v3.0.7 golang.org/x/net v0.47.0 ) -require golang.org/x/text v0.31.0 // indirect +require ( + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum index 4a6966c..9228936 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= diff --git a/internal/config/config.go b/internal/config/config.go index bce3829..bba8ec9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,16 +33,26 @@ type ServerConfig struct { DTLSListen string // 예: ":443" Domain string // 메인 도메인 ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인 + Debug bool // true 이면 디버그 모드 (예: self-signed 인증서 신뢰, 검증 스킵 등) Logging LoggingConfig // 서버용 로그 설정 } // ClientConfig 는 클라이언트 프로세스 설정을 담습니다. +// 현재 클라이언트는 다음 4가지 설정만 사용합니다. +// - ServerAddr : DTLS 서버 주소 (host:port) +// - Domain : 서버에서 등록된 도메인 (예: api.example.com) +// - ClientAPIKey : 도메인에 매핑된 64자 클라이언트 API Key +// - LocalTarget : 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080) +// +// 값은 .env/환경변수와 CLI 인자를 조합해 구성하며, +// CLI 인자가 우선, env 가 후순위로 적용됩니다. type ClientConfig struct { - ServerAddr string // DTLS 서버 주소 (host:port) - ClientID string // 클라이언트 식별자 - AuthToken string // 선택적 인증 토큰 - ServicePorts map[string]string // service name -> "127.0.0.1:PORT" + ServerAddr string // DTLS 서버 주소 (host:port) + Domain string // 서버에서 등록된 도메인 (예: api.example.com) + ClientAPIKey string // 도메인에 매핑된 64자 클라이언트 API Key + LocalTarget string // 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080) + Debug bool // true 이면 디버그 모드 (예: 서버 인증서 검증 스킵 등) Logging LoggingConfig // 클라이언트용 로그 설정 } @@ -212,12 +222,14 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) { DTLSListen: getEnvOrDefault("HOP_SERVER_DTLS_LISTEN", ":443"), Domain: os.Getenv("HOP_SERVER_DOMAIN"), ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"), + Debug: getEnvBool("HOP_SERVER_DEBUG", false), Logging: loadLoggingFromEnv(), } return cfg, nil } // LoadClientConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 클라이언트 설정을 구성합니다. +// 실제 런타임에서 사용되는 필드는 ServerAddr, Domain, ClientAPIKey, LocalTarget 입니다. func LoadClientConfigFromEnv() (*ClientConfig, error) { loadDotEnvOnce() if dotenvErr != nil { @@ -226,9 +238,10 @@ func LoadClientConfigFromEnv() (*ClientConfig, error) { cfg := &ClientConfig{ ServerAddr: os.Getenv("HOP_CLIENT_SERVER_ADDR"), - ClientID: os.Getenv("HOP_CLIENT_ID"), - AuthToken: os.Getenv("HOP_CLIENT_AUTH_TOKEN"), - ServicePorts: parseServicePortsEnv("HOP_CLIENT_SERVICE_PORTS"), + Domain: os.Getenv("HOP_CLIENT_DOMAIN"), + ClientAPIKey: os.Getenv("HOP_CLIENT_API_KEY"), + LocalTarget: os.Getenv("HOP_CLIENT_LOCAL_TARGET"), + Debug: getEnvBool("HOP_CLIENT_DEBUG", false), Logging: loadLoggingFromEnv(), } return cfg, nil diff --git a/internal/dtls/handshake.go b/internal/dtls/handshake.go new file mode 100644 index 0000000..d4cfb54 --- /dev/null +++ b/internal/dtls/handshake.go @@ -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) +} diff --git a/internal/dtls/selfsigned.go b/internal/dtls/selfsigned.go new file mode 100644 index 0000000..73c0c9e --- /dev/null +++ b/internal/dtls/selfsigned.go @@ -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 +} diff --git a/internal/dtls/transport_pion.go b/internal/dtls/transport_pion.go new file mode 100644 index 0000000..b3dda45 --- /dev/null +++ b/internal/dtls/transport_pion.go @@ -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 +} diff --git a/internal/dtls/validator_dummy.go b/internal/dtls/validator_dummy.go new file mode 100644 index 0000000..516dbef --- /dev/null +++ b/internal/dtls/validator_dummy.go @@ -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:] +}