mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-07 20:35:44 +09:00
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:
28
.env.example
28
.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
|
||||
# 서비스 매핑: name=host:port 형태
|
||||
HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080
|
||||
|
||||
# 디버깅용 플래그
|
||||
# 1. self signed 인증서를 신뢰(인증서 체인 검증 스킵)
|
||||
HOP_CLIENT_DEBUG=true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
8
go.mod
8
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
|
||||
)
|
||||
|
||||
12
go.sum
12
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
196
internal/dtls/handshake.go
Normal file
196
internal/dtls/handshake.go
Normal 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)
|
||||
}
|
||||
69
internal/dtls/selfsigned.go
Normal file
69
internal/dtls/selfsigned.go
Normal 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
|
||||
}
|
||||
194
internal/dtls/transport_pion.go
Normal file
194
internal/dtls/transport_pion.go
Normal 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
|
||||
}
|
||||
33
internal/dtls/validator_dummy.go
Normal file
33
internal/dtls/validator_dummy.go
Normal 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:]
|
||||
}
|
||||
Reference in New Issue
Block a user