From 0f32593ea59ab824989281e390923cd2f8278c60 Mon Sep 17 00:00:00 2001 From: dalbodeule <11470513+dalbodeule@users.noreply.github.com> Date: Thu, 27 Nov 2025 01:23:12 +0900 Subject: [PATCH] [feat] add ACME-based certificate management using go-acme/lego - Introduced a new `legoManager` for managing per-domain TLS certificates. - Implemented ACME HTTP-01 challenge handling with a configurable webroot directory. - Created `NewLegoManagerFromEnv` to initialize ACME settings from environment variables. - Added `verifyDomainsResolve` to validate domain DNS resolutions. - Updated `.gitignore` to include ACME cache and webroot directories. - Updated `go.mod` and `go.sum` with new dependencies, including `go-acme/lego`. --- .dockerignore | 5 +- .env.example | 40 ++- .gitignore | 4 +- cmd/client/main.go | 30 ++ cmd/server/main.go | 412 +++++++++++++++++++++++-- go.mod | 11 +- go.sum | 34 ++- internal/acme/acme.go | 518 +++++++++++++++++++++++++++++++- internal/dtls/transport_pion.go | 34 ++- internal/proxy/client.go | 171 ++++++++++- 10 files changed, 1204 insertions(+), 55 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8c9a9e9..ddc9d5f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,5 @@ # Docker build context excludes -images/ \ No newline at end of file +images/ +bin/ +acme-cache/ +acme-webroot/ \ No newline at end of file diff --git a/.env.example b/.env.example index ddbf40a..8e90bc7 100644 --- a/.env.example +++ b/.env.example @@ -47,14 +47,43 @@ HOP_SERVER_DTLS_LISTEN=:8443 # 메인 도메인 (예: example.com) 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 +# ---- ACME / Let's Encrypt (server-side) ---- +# +# ACME 계정 이메일 (필수) +# ACME account email (required) +HOP_ACME_EMAIL=admin@example.com +# +# ACME/lego 캐시 디렉터리 (인증서 및 계정 정보 저장) (필수) +# ACME/lego cache directory (stores certs and account data) (required) +HOP_ACME_CACHE_DIR=./acme-cache +# +# ACME 디렉터리 URL (선택, 기본값은 Let's Encrypt production/staging) +# ACME directory URL (optional, defaults to Let's Encrypt production/staging) +# 예: https://acme-staging-v02.api.letsencrypt.org/directory +#HOP_ACME_CA_DIR= +# +# true 이면 Let's Encrypt Staging CA 사용 (테스트용) +# If true, use Let's Encrypt Staging CA (for testing) +HOP_ACME_USE_STAGING=true +# +# 도메인 DNS가 resolve 되어야 할 기대 IP 목록 (콤마 구분, 옵션) +# Expected IPs that domains must resolve to via 1.1.1.1 DNS (comma-separated, optional) +# 예: 1.2.3.4,5.6.7.8 +#HOP_ACME_EXPECT_IPS=1.2.3.4,5.6.7.8 +# +# ACME HTTP-01 webroot 디렉터리 (필수, webroot 모드 사용 시) +# go-acme/lego 가 /.well-known/acme-challenge/{token} 파일을 생성하는 디렉터리입니다. +# 메인 HTTP 서버는 이 디렉터리에서 해당 토큰 파일을 서빙해야 합니다. +# ACME HTTP-01 webroot directory (required when using webroot mode). +# go-acme/lego writes challenge files here and the main HTTP server must serve +# /.well-known/acme-challenge/{token} from this directory. +HOP_ACME_WEBROOT=./acme-webroot + + # ---- PostgreSQL (server-side) ---- # # PostgreSQL DSN (required), e.g.: @@ -71,7 +100,6 @@ HOP_DB_DSN=postgres://user:pass@localhost:5432/hopgate?sslmode=disable # e.g. "30m", "1h" #HOP_DB_CONN_MAX_LIFETIME=30m - # ---- Client settings ---- # DTLS 서버 주소 (host:port) @@ -89,4 +117,4 @@ HOP_CLIENT_LOCAL_TARGET=127.0.0.1:8080 # 디버깅용 플래그 # 1. self signed 인증서를 신뢰(인증서 체인 검증 스킵) -HOP_CLIENT_DEBUG=true \ No newline at end of file +HOP_CLIENT_DEBUG=true diff --git a/.gitignore b/.gitignore index e6c535b..3e3e3f3 100644 --- a/.gitignore +++ b/.gitignore @@ -192,4 +192,6 @@ $RECYCLE.BIN/ # 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 +bin/ +acme-cache/ +acme-webroot/ \ No newline at end of file diff --git a/cmd/client/main.go b/cmd/client/main.go index e36b2ef..60dfa13 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "flag" "os" "strings" @@ -10,6 +11,7 @@ import ( "github.com/dalbodeule/hop-gate/internal/config" "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/proxy" ) // maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다. @@ -103,7 +105,20 @@ func main() { InsecureSkipVerify: true, MinVersion: tls.VersionTLS12, } + } else { + // 운영 모드: 시스템 루트 CA + SNI(ServerName)에 서버 도메인 설정 + rootCAs, err := x509.SystemCertPool() + if err != nil || rootCAs == nil { + rootCAs = x509.NewCertPool() + } + tlsCfg = &tls.Config{ + RootCAs: rootCAs, + MinVersion: tls.VersionTLS12, + } } + // DTLS 서버 측은 SNI(ServerName)가 HOP_SERVER_DOMAIN(cfg.Domain)과 일치하는지 검사하므로, + // 클라이언트 TLS 설정에도 반드시 도메인을 설정해준다. + tlsCfg.ServerName = finalCfg.Domain client := dtls.NewPionClient(dtls.PionClientConfig{ Addr: finalCfg.ServerAddr, @@ -131,4 +146,19 @@ func main() { "domain": hsRes.Domain, "local_target": finalCfg.LocalTarget, }) + + // 5. DTLS 세션 위에서 서버 요청을 처리하는 클라이언트 프록시 루프 시작 + clientProxy := proxy.NewClientProxy(logger, finalCfg.LocalTarget) + logger.Info("starting client proxy loop", logging.Fields{ + "local_target": finalCfg.LocalTarget, + }) + + if err := clientProxy.StartLoop(ctx, sess); err != nil { + logger.Error("client proxy loop exited with error", logging.Fields{ + "error": err.Error(), + }) + os.Exit(1) + } + + logger.Info("client proxy loop exited normally", nil) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 7c92a1b..cd3e50c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,14 +3,265 @@ package main import ( "context" "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" "os" + "path/filepath" + "strings" + "sync" + "time" + "github.com/dalbodeule/hop-gate/internal/acme" "github.com/dalbodeule/hop-gate/internal/config" "github.com/dalbodeule/hop-gate/internal/dtls" "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/protocol" "github.com/dalbodeule/hop-gate/internal/store" ) +type dtlsSessionWrapper struct { + sess dtls.Session + mu sync.Mutex +} + +// domainGateValidator 는 DTLS 핸드셰이크에서 허용된 도메인(HOP_SERVER_DOMAIN)만 통과시키기 위한 래퍼입니다. (ko) +// domainGateValidator wraps another DomainValidator and allows only the configured HOP_SERVER_DOMAIN. (en) +type domainGateValidator struct { + allowed string + inner dtls.DomainValidator + logger logging.Logger +} + +func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error { + d := strings.ToLower(strings.TrimSpace(domain)) + if v.allowed != "" && d != v.allowed { + if v.logger != nil { + v.logger.Warn("dtls handshake rejected due to mismatched domain", logging.Fields{ + "expected_domain": v.allowed, + "received_domain": d, + }) + } + return fmt.Errorf("domain %s is not allowed for dtls handshake", domain) + } + if v.inner != nil { + return v.inner.ValidateDomainAPIKey(ctx, domain, clientAPIKey) + } + return nil +} + +// ForwardHTTP 는 단일 HTTP 요청을 DTLS 세션으로 포워딩하고 응답을 돌려받습니다. +// ForwardHTTP forwards a single HTTP request over the DTLS session and returns the response. +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() + + if ctx == nil { + ctx = context.Background() + } + + // 요청 본문 읽기 + var body []byte + if req.Body != nil { + b, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + body = b + } + + // 간단한 RequestID 생성 (실제 서비스에서는 UUID 등을 사용하는 것이 좋음) + requestID := time.Now().UTC().Format("20060102T150405.000000000") + + protoReq := &protocol.Request{ + RequestID: requestID, + ClientID: "", // TODO: 클라이언트 식별자 도입 시 채우기 + ServiceName: serviceName, + Method: req.Method, + URL: req.URL.String(), + Header: req.Header.Clone(), + Body: body, + } + + log := logger.With(logging.Fields{ + "component": "http_to_dtls", + "request_id": requestID, + "method": req.Method, + "url": req.URL.String(), + }) + + log.Info("forwarding http request over dtls", logging.Fields{ + "host": req.Host, + "scheme": req.URL.Scheme, + }) + + enc := json.NewEncoder(w.sess) + if err := enc.Encode(protoReq); err != nil { + log.Error("failed to encode protocol request", logging.Fields{ + "error": err.Error(), + }) + return nil, err + } + + var protoResp protocol.Response + dec := json.NewDecoder(w.sess) + if err := dec.Decode(&protoResp); err != nil { + log.Error("failed to decode protocol response", logging.Fields{ + "error": err.Error(), + }) + return nil, err + } + + log.Info("received dtls response", logging.Fields{ + "status": protoResp.Status, + "error": protoResp.Error, + }) + + return &protoResp, nil +} + +var ( + sessionsMu sync.RWMutex + sessionsByDomain = make(map[string]*dtlsSessionWrapper) +) + +func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) { + d := strings.ToLower(strings.TrimSpace(domain)) + if d == "" { + return + } + w := &dtlsSessionWrapper{sess: sess} + sessionsMu.Lock() + sessionsByDomain[d] = w + sessionsMu.Unlock() + + logger.Info("registered dtls session for domain", logging.Fields{ + "domain": d, + "sid": sess.ID(), + }) +} + +func getSessionForHost(host string) *dtlsSessionWrapper { + // host may contain port (e.g. "example.com:443"); strip port. + h := host + if i := strings.Index(h, ":"); i != -1 { + h = h[:i] + } + h = strings.ToLower(strings.TrimSpace(h)) + if h == "" { + return nil + } + sessionsMu.RLock() + defer sessionsMu.RUnlock() + return sessionsByDomain[h] +} + +func newHTTPHandler(logger logging.Logger) http.Handler { + // ACME webroot (for HTTP-01) is read from env; must match HOP_ACME_WEBROOT used by lego. + webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT")) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + log := logger.With(logging.Fields{ + "component": "http_entry", + "method": r.Method, + "url": r.URL.String(), + "host": r.Host, + }) + log.Info("incoming http request", nil) + + // 1. ACME HTTP-01 webroot handling + // /.well-known/acme-challenge/{token} 는 HOP_ACME_WEBROOT 디렉터리에서 정적 파일로 서빙합니다. + if webroot != "" && strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") { + token := strings.Trim(r.URL.Path, "/") + if token == "" { + http.Error(w, "invalid acme challenge path", http.StatusBadRequest) + return + } + filePath := filepath.Join(webroot, token) + + log := logger.With(logging.Fields{ + "component": "acme_http01", + "host": r.Host, + "token": token, + "path": r.URL.Path, + "file": filePath, + }) + log.Info("serving acme http-01 challenge", nil) + + f, err := os.Open(filePath) + if err != nil { + log.Error("failed to open acme challenge file", logging.Fields{ + "error": err.Error(), + }) + http.NotFound(w, r) + return + } + defer f.Close() + + // ACME challenge 응답은 일반적으로 text/plain. + w.Header().Set("Content-Type", "text/plain") + if _, err := io.Copy(w, f); err != nil { + log.Error("failed to write acme challenge response", logging.Fields{ + "error": err.Error(), + }) + } + return + } + + // 2. 일반 HTTP 요청은 DTLS 를 통해 클라이언트로 포워딩 + // 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선. + serviceName := "web" + + sessWrapper := getSessionForHost(r.Host) + if sessWrapper == nil { + log.Warn("no dtls session for host", logging.Fields{ + "host": r.Host, + }) + http.Error(w, "no backend client available", http.StatusBadGateway) + return + } + + // r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 + defer r.Body.Close() + + ctx := r.Context() + protoResp, err := sessWrapper.ForwardHTTP(ctx, logger, r, serviceName) + if err != nil { + log.Error("forward over dtls failed", logging.Fields{ + "error": err.Error(), + }) + http.Error(w, "dtls forward failed", http.StatusBadGateway) + return + } + + // 응답 헤더/바디 복원 + for k, vs := range protoResp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + if protoResp.Status == 0 { + protoResp.Status = http.StatusOK + } + w.WriteHeader(protoResp.Status) + if len(protoResp.Body) > 0 { + if _, err := w.Write(protoResp.Body); err != nil { + log.Warn("failed to write http response body", logging.Fields{ + "error": err.Error(), + }) + } + } + + log.Info("http request completed", logging.Fields{ + "status": protoResp.Status, + "elapsed_ms": time.Since(start).Milliseconds(), + "service_name": serviceName, + }) + }) +} + func main() { logger := logging.NewStdJSONLogger("server") @@ -32,8 +283,9 @@ func main() { "debug": cfg.Debug, }) - // 2. PostgreSQL 연결 및 스키마 초기화 (ent 기반) ctx := context.Background() + + // 2. PostgreSQL 연결 및 스키마 초기화 (ent 기반) dbClient, err := store.OpenPostgresFromEnv(ctx, logger) if err != nil { logger.Error("failed to init postgres for admin/domain store", logging.Fields{ @@ -47,14 +299,43 @@ func main() { "component": "store", }) - // 3. DTLS 서버 리스너 생성 (pion/dtls 기반) - // - // Debug 모드일 때는 self-signed localhost 인증서를 사용해 테스트 할 수 있도록 - // internal/dtls.NewSelfSignedLocalhostConfig() 를 사용합니다. - // 운영 환경에서는 internal/acme.Manager 를 통해 얻은 tls.Config 를 - // PionServerConfig.TLSConfig 로 전달해야 합니다. + // 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되 + // ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다. + // 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed + // but ACME is still attempted in staging mode. var tlsCfg *tls.Config + + // ACME 를 위해 사용할 도메인 목록 구성 + var domains []string + if cfg.Domain != "" { + domains = append(domains, cfg.Domain) + } + domains = append(domains, cfg.ProxyDomains...) + + // Debug 모드에서는 반드시 Staging CA 를 사용하도록 강제 if cfg.Debug { + _ = os.Setenv("HOP_ACME_USE_STAGING", "true") + } + + // ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장 + acmeMgr, err := acme.NewLegoManagerFromEnv(ctx, logger, domains) + if err != nil { + logger.Error("failed to initialize ACME lego manager", logging.Fields{ + "error": err.Error(), + "domains": domains, + }) + os.Exit(1) + } + acmeTLSCfg := acmeMgr.TLSConfig() + + logger.Info("acme tls config initialized", logging.Fields{ + "domains": domains, + "use_staging": cfg.Debug, + }) + + if cfg.Debug { + // Debug 모드: DTLS 자체는 self-signed localhost 인증서를 사용하지만, + // ACME Staging 을 통해 실제 도메인 인증서도 동시에 관리합니다. tlsCfg, err = dtls.NewSelfSignedLocalhostConfig() if err != nil { logger.Error("failed to create self-signed localhost cert", logging.Fields{ @@ -63,13 +344,42 @@ func main() { os.Exit(1) } logger.Warn("using self-signed localhost certificate for DTLS (debug mode)", logging.Fields{ - "note": "do not use this in production", + "note": "acme is running in staging mode; do not use this configuration in production", }) + } else { + // Production 모드: DTLS/HTTPS 모두 ACME 인증서를 직접 사용 + tlsCfg = acmeTLSCfg } + // DTLS 서버는 HOP_SERVER_DOMAIN 으로 지정된 도메인에 대한 연결만 수락해야 합니다. + // 이를 위해 GetCertificate 를 래핑하여 SNI 검증 로직을 추가합니다. + // 주의: HTTPS 서버용 tlsCfg 에 영향을 주지 않도록 Clone()을 사용합니다. + dtlsTLSConfig := tlsCfg.Clone() + if cfg.Domain != "" { + nextGetCert := dtlsTLSConfig.GetCertificate + dtlsTLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + // SNI 검증: 설정된 도메인과 일치하지 않으면 핸드셰이크 거부 + // ServerName이 비어있는 경우(클라이언트가 SNI 미전송 시)는 검증을 건너뜁니다. + if hello.ServerName != "" && !strings.EqualFold(hello.ServerName, cfg.Domain) { + return nil, fmt.Errorf("dtls: invalid SNI %q, expected %q", hello.ServerName, cfg.Domain) + } + + // 기존 로직 수행 + if nextGetCert != nil { + return nextGetCert(hello) + } + // Debug 모드 등에서 GetCertificate 가 없는 경우 Certificates 필드 사용 + if len(dtlsTLSConfig.Certificates) > 0 { + return &dtlsTLSConfig.Certificates[0], nil + } + return nil, fmt.Errorf("dtls: no certificate found for %q", hello.ServerName) + } + } + + // 4. DTLS 서버 리스너 생성 (pion/dtls 기반) dtlsServer, err := dtls.NewPionServer(dtls.PionServerConfig{ Addr: cfg.DTLSListen, - TLSConfig: tlsCfg, // debug 모드면 self-signed, 아니면 nil(기본값/ACME로 교체 예정) + TLSConfig: dtlsTLSConfig, }) if err != nil { logger.Error("failed to start dtls server", logging.Fields{ @@ -83,16 +393,56 @@ func main() { "addr": cfg.DTLSListen, }) - // 3. 도메인 검증기 준비 (현재는 Dummy 구현) - // - // DomainValidator 는 (domain, client_api_key) 조합을 검증합니다. - // 지금은 DummyDomainValidator 로 모두 허용하지만, - // 향후 ent + PostgreSQL 기반 구현으로 교체해야 합니다. - validator := dtls.DummyDomainValidator{ + // 5. HTTP / HTTPS 서버 시작 + httpHandler := newHTTPHandler(logger) + + // HTTP: 평문 포트 + httpSrv := &http.Server{ + Addr: cfg.HTTPListen, + Handler: httpHandler, + } + go func() { + logger.Info("http server listening", logging.Fields{ + "addr": cfg.HTTPListen, + }) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http server error", logging.Fields{ + "error": err.Error(), + }) + } + }() + + // HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능) + httpsSrv := &http.Server{ + Addr: cfg.HTTPSListen, + Handler: httpHandler, + TLSConfig: acmeTLSCfg, + } + go func() { + logger.Info("https server listening", logging.Fields{ + "addr": cfg.HTTPSListen, + }) + if err := httpsSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + logger.Error("https server error", logging.Fields{ + "error": err.Error(), + }) + } + }() + + // 6. 도메인 검증기 준비 (현재는 Dummy 구현, 추후 ent + PostgreSQL 기반으로 교체 예정) + baseValidator := dtls.DummyDomainValidator{ Logger: logger, } - // 4. DTLS Accept 루프 + Handshake + // DTLS 핸드셰이크 단계에서 HOP_SERVER_DOMAIN 으로 설정된 도메인만 허용하도록 래핑합니다. + allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain)) + var validator dtls.DomainValidator = &domainGateValidator{ + allowed: allowedDomain, + inner: baseValidator, + logger: logger, + } + + // 7. DTLS Accept 루프 + Handshake for { sess, err := dtlsServer.Accept() if err != nil { @@ -104,8 +454,8 @@ func main() { // 각 세션별로 goroutine 에서 핸드셰이크 및 후속 처리를 수행합니다. go func(s dtls.Session) { - defer s.Close() - + // NOTE: 세션은 HTTP↔DTLS 터널링에 계속 사용해야 하므로 이곳에서 Close 하지 않습니다. + // 세션 종료/타임아웃 관리는 별도의 세션 매니저(TODO)에서 담당해야 합니다. hsRes, err := dtls.PerformServerHandshake(ctx, s, validator, logger) if err != nil { // PerformServerHandshake 내부에서 이미 상세 로그를 남기므로 여기서는 요약만 기록합니다. @@ -113,6 +463,9 @@ func main() { "session_id": s.ID(), "error": err.Error(), }) + // 핸드셰이크 실패 시 세션을 명시적으로 종료하여 invalid SNI 등 오류에서 + // 연결이 열린 채로 남지 않도록 합니다. + _ = s.Close() return } @@ -122,6 +475,29 @@ func main() { "domain": hsRes.Domain, }) + // Handshake 가 완료된 세션을 도메인에 매핑해 HTTP 요청 시 사용할 수 있도록 등록합니다. + registerSessionForDomain(hsRes.Domain, s, logger) + + // Handshake 가 정상적으로 끝난 이후, 실제로 해당 도메인에 대해 ACME 인증서를 확보/연장합니다. + // Debug 모드에서도 ACME 는 항상 시도하지만, 위에서 HOP_ACME_USE_STAGING=true 로 설정되어 + // Staging CA 를 사용하게 됩니다. + if hsRes.Domain != "" { + go func(domain string) { + acmeLogger := logger.With(logging.Fields{ + "component": "acme_post_handshake", + "domain": domain, + "debug": cfg.Debug, + }) + if _, err := acme.NewLegoManagerFromEnv(context.Background(), acmeLogger, []string{domain}); err != nil { + acmeLogger.Error("failed to ensure acme certificate after dtls handshake", logging.Fields{ + "error": err.Error(), + }) + return + } + acmeLogger.Info("acme certificate ensured after dtls handshake", nil) + }(hsRes.Domain) + } + // TODO: // - hsRes.Domain 과 연결된 세션을 proxy 레이어에 등록 // - HTTP 요청을 이 세션을 통해 해당 클라이언트로 라우팅 diff --git a/go.mod b/go.mod index a5fdb4b..8621ae9 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,8 @@ go 1.25.4 require ( entgo.io/ent v0.14.5 - github.com/google/uuid v1.3.0 + github.com/go-acme/lego/v4 v4.28.1 + github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/pion/dtls/v3 v3.0.7 golang.org/x/net v0.47.0 @@ -15,9 +16,12 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-openapi/inflect v0.19.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect + github.com/miekg/dns v1.1.68 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/transport/v3 v3.0.7 // indirect @@ -26,5 +30,8 @@ require ( github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 41da934..4646b1f 100644 --- a/go.sum +++ b/go.sum @@ -10,16 +10,22 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= -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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q= +github.com/go-acme/lego/v4 v4.28.1/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -32,6 +38,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= @@ -40,14 +48,14 @@ 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= @@ -58,7 +66,13 @@ golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/acme/acme.go b/internal/acme/acme.go index 5e0ab96..c547088 100644 --- a/internal/acme/acme.go +++ b/internal/acme/acme.go @@ -1,15 +1,107 @@ package acme -import "crypto/tls" +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" -// Manager 는 ACME 기반 인증서 관리를 추상화합니다. + "github.com/dalbodeule/hop-gate/internal/logging" + webroot2 "github.com/go-acme/lego/v4/providers/http/webroot" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/registration" +) + +// Manager 는 ACME 기반 인증서 관리를 추상화합니다. (ko) +// Manager abstracts ACME-based certificate management. (en) type Manager interface { - // TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다. + // TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다. (ko) + // TLSConfig returns a tls.Config to be used by HTTPS and DTLS servers. (en) TLSConfig() *tls.Config } -// NewDummyManager 는 초기 개발 단계를 위한 더미 구현입니다. -// 실제 ACME 연동 전까지 self-signed 등의 임시 인증서를 제공하도록 확장할 수 있습니다. +// legoManager 는 go-acme/lego 를 사용해 도메인별 TLS 인증서를 관리합니다. (ko) +// legoManager manages per-domain TLS certificates using go-acme/lego. (en) +type legoManager struct { + cacheDir string + domains []string + logger logging.Logger + tlsConfig *tls.Config +} + +func (m *legoManager) TLSConfig() *tls.Config { + return m.tlsConfig +} + +// getCertificate 는 ClientHello 의 SNI 를 기반으로 디스크에서 최신 인증서를 로드합니다. (ko) +// getCertificate loads the latest certificate from disk based on the SNI in ClientHello. (en) +func (m *legoManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + domain := strings.ToLower(strings.TrimSpace(hello.ServerName)) + if domain == "" && len(m.domains) > 0 { + // SNI 가 비어있으면 첫 번째 도메인으로 fallback. (ko) + // If SNI is empty, fall back to the first configured domain. (en) + domain = m.domains[0] + } + if domain == "" { + return nil, fmt.Errorf("no server name (SNI) provided and no default domain configured") + } + + // 정규화된 도메인을 기준으로 cert/key 경로 구성. (ko) + // Build cert/key paths based on normalized domain. (en) + certPath := filepath.Join(m.cacheDir, domain+".crt") + keyPath := filepath.Join(m.cacheDir, domain+".key") + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + m.logger.Error("failed to load certificate for domain", logging.Fields{ + "domain": domain, + "cert_path": certPath, + "key_path": keyPath, + "error": err.Error(), + }) + // 도메인이 리스트에 있고, 첫 번째 도메인과 다르면 첫 번째 도메인으로 한 번 더 시도. (ko) + // If this is not the first domain, attempt to fall back to the first domain. (en) + if len(m.domains) > 0 && domain != m.domains[0] { + fallback := m.domains[0] + fCertPath := filepath.Join(m.cacheDir, fallback+".crt") + fKeyPath := filepath.Join(m.cacheDir, fallback+".key") + fCert, fErr := tls.LoadX509KeyPair(fCertPath, fKeyPath) + if fErr == nil { + m.logger.Warn("falling back to default certificate for domain", logging.Fields{ + "requested_domain": domain, + "fallback_domain": fallback, + }) + return &fCert, nil + } + m.logger.Error("failed to load fallback certificate", logging.Fields{ + "fallback_domain": fallback, + "cert_path": fCertPath, + "key_path": fKeyPath, + "error": fErr.Error(), + }) + } + return nil, err + } + + return &cert, nil +} + +// NewDummyManager 는 초기 개발 단계를 위한 더미 구현입니다. (ko) +// NewDummyManager is a placeholder manager for early development. (en) func NewDummyManager() Manager { return &dummyManager{} } @@ -17,6 +109,420 @@ func NewDummyManager() Manager { type dummyManager struct{} func (d *dummyManager) TLSConfig() *tls.Config { - // TODO: 실제 인증서 로딩/ACME 연동 구현 return &tls.Config{} } + +// legoUser implements lego.User. +type legoUser struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration,omitempty"` + KeyPEM []byte `json:"key_pem,omitempty"` + key crypto.PrivateKey // not marshaled, derived from KeyPEM +} + +func (u *legoUser) GetEmail() string { + return u.Email +} + +func (u *legoUser) GetRegistration() *registration.Resource { + return u.Registration +} + +func (u *legoUser) GetPrivateKey() crypto.PrivateKey { + return u.key +} + +// NewLegoManagerFromEnv 는 환경변수와 도메인 목록을 기반으로 lego 기반 ACME 매니저를 생성합니다. (ko) +// NewLegoManagerFromEnv creates an ACME manager based on environment variables and a list of domains. (en) +// +// Required env: +// - HOP_ACME_EMAIL : account email for Let's Encrypt +// - HOP_ACME_CACHE_DIR : directory to store certificates and lego account data +// +// Optional env: +// - HOP_ACME_CA_DIR : ACME directory URL (default: Let's Encrypt production) +// - HOP_ACME_USE_STAGING : if true, use Let's Encrypt staging CA instead of production +// - HOP_ACME_EXPECT_IPS : comma-separated list of IPs that domains must resolve to (via 1.1.1.1 DNS) +func NewLegoManagerFromEnv(ctx context.Context, logger logging.Logger, domains []string) (Manager, error) { + email := strings.TrimSpace(os.Getenv("HOP_ACME_EMAIL")) + cacheDir := strings.TrimSpace(os.Getenv("HOP_ACME_CACHE_DIR")) + caDir := strings.TrimSpace(os.Getenv("HOP_ACME_CA_DIR")) + useStaging := getEnvBool("HOP_ACME_USE_STAGING", false) + expectedIPs := parseCSVEnv("HOP_ACME_EXPECT_IPS") + + if email == "" { + return nil, fmt.Errorf("HOP_ACME_EMAIL is required") + } + if cacheDir == "" { + return nil, fmt.Errorf("HOP_ACME_CACHE_DIR is required") + } + if caDir == "" { + if useStaging { + caDir = lego.LEDirectoryStaging + } else { + caDir = lego.LEDirectoryProduction + } + } + if len(domains) == 0 { + return nil, fmt.Errorf("at least one domain is required for ACME") + } + + // Normalize and deduplicate domain list. + domainSet := make(map[string]struct{}) + for _, d := range domains { + d = strings.TrimSpace(strings.ToLower(d)) + if d == "" { + continue + } + domainSet[d] = struct{}{} + } + if len(domainSet) == 0 { + return nil, fmt.Errorf("no valid domains after normalization") + } + var uniqDomains []string + for d := range domainSet { + uniqDomains = append(uniqDomains, d) + } + + logger.Info("acme lego manager initializing", logging.Fields{ + "email": email, + "cache_dir": cacheDir, + "ca_dir": caDir, + "use_staging": useStaging, + "domains": uniqDomains, + }) + + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return nil, fmt.Errorf("create acme cache dir: %w", err) + } + + // 1. DNS 확인: 1.1.1.1 DNS를 사용해 도메인이 예상 IP에 연결되어 있는지 체크. (ko) + // 1. DNS check: use 1.1.1.1 resolver to ensure domains resolve to expected IPs. (en) + if err := verifyDomainsResolve(ctx, logger, uniqDomains, expectedIPs); err != nil { + return nil, err + } + + // 2. lego user 로드/생성. (ko) + // 2. Load or create lego user. (en) + user, err := loadOrCreateUser(cacheDir, email) + if err != nil { + return nil, fmt.Errorf("load/create lego user: %w", err) + } + + // 3. lego config & client 생성. (ko) + // 3. Build lego config & client. (en) + cfg := lego.NewConfig(user) + cfg.CADirURL = caDir + cfg.Certificate = lego.CertificateConfig{ + KeyType: certKeyType(), + } + + client, err := lego.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("new lego client: %w", err) + } + + // Account registration (if not yet registered). + if user.Registration == nil { + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return nil, fmt.Errorf("lego registration: %w", err) + } + user.Registration = reg + if err := saveUser(cacheDir, user); err != nil { + return nil, fmt.Errorf("save lego user after registration: %w", err) + } + } + + // 4. HTTP-01 챌린지 프로바이더 설정 (webroot 방식). (ko) + // 4. Configure HTTP-01 challenge provider using webroot. (en) + // + // go-acme/lego 가 자체적으로 포트를 바인딩하지 않고, + // 지정된 디렉터리(HOP_ACME_WEBROOT)에 챌린지 파일을 생성하도록 합니다. + // 메인 HTTP 서버는 /.well-known/acme-challenge/* 요청을 이 디렉터리에서 서빙해야 합니다. + // + // Using webroot avoids lego binding its own HTTP server; instead, it writes the + // challenge files into HOP_ACME_WEBROOT and the main HTTP server must serve + // /.well-known/acme-challenge/* from that directory. + webroot := strings.TrimSpace(os.Getenv("HOP_ACME_WEBROOT")) + if webroot == "" { + return nil, fmt.Errorf("HOP_ACME_WEBROOT is required when using ACME webroot mode") + } + if err := os.MkdirAll(webroot, 0o755); err != nil { + return nil, fmt.Errorf("create acme webroot dir: %w", err) + } + + provider, err := webroot2.NewHTTPProvider(webroot) + if err := client.Challenge.SetHTTP01Provider(provider); err != nil { + return nil, fmt.Errorf("set http-01 filesystem provider: %w", err) + } + + // 5. 도메인별 인증서 확보/갱신 및 캐시 디렉터리에 저장. (ko) + // 5. Ensure certificates per domain and store them in cache directory. (en) + for _, domain := range uniqDomains { + if _, err := ensureCertForDomain(ctx, logger, client, cacheDir, domain); err != nil { + return nil, fmt.Errorf("ensure cert for domain %s: %w", domain, err) + } + } + + // 6. tls.Config 생성 (GetCertificate 기반). (ko) + // 6. Build tls.Config using GetCertificate callback. (en) + mgr := &legoManager{ + cacheDir: cacheDir, + domains: uniqDomains, + logger: logger.With(logging.Fields{"component": "acme_lego_manager"}), + } + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + // 각 핸드셰이크마다 최신 인증서를 디스크에서 읽어오도록 합니다. + // Load from disk on each handshake so newly issued certificates are picked up + // without restarting the server. + GetCertificate: mgr.getCertificate, + } + mgr.tlsConfig = tlsCfg + + return mgr, nil +} + +// verifyDomainsResolve checks that each domain resolves via 1.1.1.1 and, +// if expectedIPs is non-empty, that at least one of the resolved IPs matches. +func verifyDomainsResolve(ctx context.Context, logger logging.Logger, domains, expectedIPs []string) error { + if ctx == nil { + ctx = context.Background() + } + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 2 * time.Second} + return d.DialContext(ctx, "udp", "1.1.1.1:53") + }, + } + + expectedSet := make(map[string]struct{}) + for _, ip := range expectedIPs { + ip = strings.TrimSpace(ip) + if ip == "" { + continue + } + expectedSet[ip] = struct{}{} + } + + for _, domain := range domains { + ips, err := resolver.LookupHost(ctx, domain) + if err != nil { + logger.Error("acme dns resolution failed", logging.Fields{ + "domain": domain, + "error": err.Error(), + }) + return fmt.Errorf("dns resolution failed for %s: %w", domain, err) + } + logger.Info("acme dns resolution", logging.Fields{ + "domain": domain, + "ips": ips, + }) + + if len(expectedSet) == 0 { + // No expected IPs configured; DNS resolution success is enough. + continue + } + + match := false + for _, ip := range ips { + if _, ok := expectedSet[ip]; ok { + match = true + break + } + } + if !match { + return fmt.Errorf("dns resolution for %s did not match any expected IPs", domain) + } + } + + return nil +} + +// ensureCertForDomain loads an existing certificate for the domain from cacheDir, +// checks its expiration, and renews or obtains a new certificate via lego if needed. +func ensureCertForDomain(ctx context.Context, logger logging.Logger, client *lego.Client, cacheDir, domain string) (tls.Certificate, error) { + certPath := filepath.Join(cacheDir, domain+".crt") + keyPath := filepath.Join(cacheDir, domain+".key") + + // Try to load an existing certificate. + if _, err := os.Stat(certPath); err == nil { + if _, err := os.Stat(keyPath); err == nil { + existing, err := tls.LoadX509KeyPair(certPath, keyPath) + if err == nil { + // Check expiration. + leaf, err := parseLeaf(&existing) + if err == nil { + // If the cert is valid for more than 30 days, reuse. + if time.Until(leaf.NotAfter) > 30*24*time.Hour { + logger.Info("using existing certificate from cache", logging.Fields{ + "domain": domain, + "not_after": leaf.NotAfter, + "cache_path": certPath, + }) + return existing, nil + } + logger.Info("existing certificate is close to expiry, will renew", logging.Fields{ + "domain": domain, + "not_after": leaf.NotAfter, + }) + } + } + } + } + + // No valid certificate found; obtain a new one via ACME. + logger.Info("requesting new certificate via ACME", logging.Fields{ + "domain": domain, + }) + + req := certificate.ObtainRequest{ + Domains: []string{domain}, + Bundle: true, + } + certRes, err := client.Certificate.Obtain(req) + if err != nil { + return tls.Certificate{}, fmt.Errorf("obtain certificate: %w", err) + } + + if err := os.WriteFile(certPath, certRes.Certificate, 0o600); err != nil { + return tls.Certificate{}, fmt.Errorf("write cert file: %w", err) + } + if err := os.WriteFile(keyPath, certRes.PrivateKey, 0o600); err != nil { + return tls.Certificate{}, fmt.Errorf("write key file: %w", err) + } + + logger.Info("stored new certificate", logging.Fields{ + "domain": domain, + "cert_path": certPath, + "key_path": keyPath, + "not_after": time.Now().Add(90 * 24 * time.Hour), // approximate + }) + + return tls.LoadX509KeyPair(certPath, keyPath) +} + +func parseLeaf(cert *tls.Certificate) (*x509.Certificate, error) { + if cert == nil || len(cert.Certificate) == 0 { + return nil, errors.New("empty certificate") + } + return x509.ParseCertificate(cert.Certificate[0]) +} + +// uniqueNamesFromCert returns a list of DNS names / CN from the certificate. +func uniqueNamesFromCert(cert *tls.Certificate) []string { + leaf, err := parseLeaf(cert) + if err != nil { + return nil + } + names := make(map[string]struct{}) + if leaf.Subject.CommonName != "" { + names[strings.ToLower(leaf.Subject.CommonName)] = struct{}{} + } + for _, n := range leaf.DNSNames { + names[strings.ToLower(n)] = struct{}{} + } + var out []string + for n := range names { + out = append(out, n) + } + return out +} + +// loadOrCreateUser loads an existing lego user from cacheDir or creates a new one. +func loadOrCreateUser(cacheDir, email string) (*legoUser, error) { + userPath := filepath.Join(cacheDir, "lego_user.json") + + if data, err := os.ReadFile(userPath); err == nil { + var u legoUser + if err := json.Unmarshal(data, &u); err == nil && u.Email == email && len(u.KeyPEM) > 0 { + key, err := x509.ParseECPrivateKey(u.KeyPEM) + if err == nil { + u.key = key + return &u, nil + } + } + } + + // Create new user with a new key. + priv, err := ecdsa.GenerateKey(elliptic.P256(), randReader) + if err != nil { + return nil, fmt.Errorf("generate ecdsa key: %w", err) + } + keyBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("marshal ecdsa key: %w", err) + } + + u := &legoUser{ + Email: email, + KeyPEM: keyBytes, + key: priv, + } + if err := saveUser(cacheDir, u); err != nil { + return nil, err + } + return u, nil +} + +// saveUser persists the lego user to disk. +func saveUser(cacheDir string, u *legoUser) error { + userPath := filepath.Join(cacheDir, "lego_user.json") + data, err := json.MarshalIndent(u, "", " ") + if err != nil { + return fmt.Errorf("marshal lego user: %w", err) + } + if err := os.WriteFile(userPath, data, 0o600); err != nil { + return fmt.Errorf("write lego user file: %w", err) + } + return nil +} + +// certKeyType returns the preferred key type for new certificates. +func certKeyType() certcrypto.KeyType { + return certcrypto.EC256 +} + +// randReader wraps crypto/rand.Reader so it can be swapped in tests if needed. +type randReaderType struct{} + +func (randReaderType) Read(p []byte) (n int, err error) { + return rand.Read(p) +} + +var randReader = randReaderType{} + +// getEnvBool is a local helper to read boolean env vars. +func getEnvBool(key string, def bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + if v == "" { + return def + } + switch v { + case "1", "true", "yes", "y", "on": + return true + case "0", "false", "no", "n", "off": + return false + default: + return def + } +} + +// parseCSVEnv is a local helper to parse comma-separated env vars into []string. +func parseCSVEnv(key string) []string { + raw := os.Getenv(key) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/dtls/transport_pion.go b/internal/dtls/transport_pion.go index b3dda45..ae88c6c 100644 --- a/internal/dtls/transport_pion.go +++ b/internal/dtls/transport_pion.go @@ -54,12 +54,33 @@ func NewPionServer(cfg PionServerConfig) (Server, error) { return nil, fmt.Errorf("resolve udp addr: %w", err) } - dtlsCfg := &piondtls.Config{ - Certificates: cfg.TLSConfig.Certificates, - InsecureSkipVerify: cfg.TLSConfig.InsecureSkipVerify, - // 필요 시 RootCAs, ClientAuth, ExtendedMasterSecret 등을 추가 설정 + // tls.Config.GetCertificate (crypto/tls) → pion/dtls.GetCertificate 어댑터 + var getCert func(*piondtls.ClientHelloInfo) (*tls.Certificate, error) + if cfg.TLSConfig.GetCertificate != nil { + tlsGetCert := cfg.TLSConfig.GetCertificate + getCert = func(chi *piondtls.ClientHelloInfo) (*tls.Certificate, error) { + if chi == nil { + return tlsGetCert(&tls.ClientHelloInfo{}) + } + // ACME 매니저는 주로 SNI(ServerName)에 기반해 인증서를 선택하므로, + // 필요한 최소 필드만 복사해서 전달한다. + return tlsGetCert(&tls.ClientHelloInfo{ + ServerName: chi.ServerName, + }) + } } + dtlsCfg := &piondtls.Config{ + // 서버가 사용할 인증서 설정: 정적 Certificates + GetCertificate 어댑터 + Certificates: cfg.TLSConfig.Certificates, + GetCertificate: getCert, + InsecureSkipVerify: cfg.TLSConfig.InsecureSkipVerify, + ClientAuth: piondtls.ClientAuthType(cfg.TLSConfig.ClientAuth), + ClientCAs: cfg.TLSConfig.ClientCAs, + RootCAs: cfg.TLSConfig.RootCAs, + ServerName: cfg.TLSConfig.ServerName, + // 필요 시 ExtendedMasterSecret 등을 추가 설정 + } l, err := piondtls.Listen("udp", udpAddr, dtlsCfg) if err != nil { return nil, fmt.Errorf("dtls listen: %w", err) @@ -154,9 +175,12 @@ func (c *pionClient) Connect() (Session, error) { } dtlsCfg := &piondtls.Config{ + // 클라이언트는 서버 인증을 위해 RootCAs/ServerName 만 사용. + // (현재는 클라이언트 인증서 사용 계획이 없으므로 GetCertificate 는 전달하지 않는다.) Certificates: c.tlsConfig.Certificates, InsecureSkipVerify: c.tlsConfig.InsecureSkipVerify, - // 필요 시 ServerName, RootCAs 등 추가 설정 + RootCAs: c.tlsConfig.RootCAs, + ServerName: c.tlsConfig.ServerName, } type result struct { diff --git a/internal/proxy/client.go b/internal/proxy/client.go index 67b1eab..a756fe3 100644 --- a/internal/proxy/client.go +++ b/internal/proxy/client.go @@ -1,19 +1,178 @@ package proxy import ( + "bytes" "context" + "encoding/json" + "fmt" + "io" + "net" "net/http" + "net/url" + "time" + + "github.com/dalbodeule/hop-gate/internal/dtls" + "github.com/dalbodeule/hop-gate/internal/logging" + "github.com/dalbodeule/hop-gate/internal/protocol" ) -// ClientProxy 는 서버로부터 받은 요청을 로컬 HTTP 서비스로 전달하는 클라이언트 측 프록시입니다. +// ClientProxy 는 서버로부터 받은 요청을 로컬 HTTP 서비스로 전달하는 클라이언트 측 프록시입니다. (ko) +// ClientProxy forwards requests from the server to local HTTP services. (en) type ClientProxy struct { - HTTPClient *http.Client + HTTPClient *http.Client + Logger logging.Logger + LocalTarget string // e.g. "127.0.0.1:8080" +} + +// NewClientProxy 는 기본 HTTP 클라이언트 및 로거를 사용해 ClientProxy 를 생성합니다. (ko) +// NewClientProxy creates a ClientProxy with a default HTTP client and logger. (en) +func NewClientProxy(logger logging.Logger, localTarget string) *ClientProxy { + if logger == nil { + logger = logging.NewStdJSONLogger("client_proxy") + } + return &ClientProxy{ + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, + Logger: logger.With(logging.Fields{"component": "client_proxy"}), + LocalTarget: localTarget, + } } // StartLoop 는 DTLS 세션에서 protocol.Request 를 읽고 로컬 HTTP 요청을 수행한 뒤 -// protocol.Response 를 다시 세션으로 쓰는 루프를 의미합니다. -// 실제 구현은 dtls.Session, protocol.{Request,Response} 를 조합해 작성합니다. -func (p *ClientProxy) StartLoop(ctx context.Context) error { - // TODO: DTLS 세션 읽기/쓰기 및 로컬 HTTP 호출 구현 +// protocol.Response 를 다시 세션으로 쓰는 루프를 실행합니다. (ko) +// StartLoop reads protocol.Request messages from the DTLS session, performs local HTTP +// requests, and writes back protocol.Response objects. (en) +func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { + if ctx == nil { + ctx = context.Background() + } + log := p.Logger + + dec := json.NewDecoder(sess) + enc := json.NewEncoder(sess) + + for { + select { + case <-ctx.Done(): + log.Info("client proxy loop stopping due to context cancellation", logging.Fields{ + "reason": ctx.Err().Error(), + }) + return nil + default: + } + + var req protocol.Request + if err := dec.Decode(&req); err != nil { + if err == io.EOF { + log.Info("dtls session closed by server", nil) + return nil + } + log.Error("failed to decode protocol request", logging.Fields{ + "error": err.Error(), + }) + return err + } + + start := time.Now() + logReq := log.With(logging.Fields{ + "request_id": req.RequestID, + "service": req.ServiceName, + "method": req.Method, + "url": req.URL, + "client_id": req.ClientID, + "local_target": p.LocalTarget, + }) + logReq.Info("received protocol request from server", nil) + + resp := protocol.Response{ + RequestID: req.RequestID, + Header: make(map[string][]string), + } + + // 로컬 HTTP 요청 수행 + if err := p.forwardToLocal(ctx, &req, &resp); err != nil { + resp.Status = http.StatusBadGateway + resp.Error = err.Error() + logReq.Error("local http request failed", logging.Fields{ + "error": err.Error(), + }) + } + + if err := enc.Encode(&resp); err != nil { + logReq.Error("failed to encode protocol response", logging.Fields{ + "error": err.Error(), + }) + return err + } + + logReq.Info("protocol response sent to server", logging.Fields{ + "status": resp.Status, + "elapsed_ms": time.Since(start).Milliseconds(), + "error": resp.Error, + }) + } +} + +// forwardToLocal 는 protocol.Request 를 로컬 HTTP 요청으로 변환하고 protocol.Response 를 채웁니다. (ko) +// forwardToLocal converts a protocol.Request into a local HTTP request and fills protocol.Response. (en) +func (p *ClientProxy) forwardToLocal(ctx context.Context, preq *protocol.Request, presp *protocol.Response) error { + if p.LocalTarget == "" { + return fmt.Errorf("local target is empty") + } + + // 요청 URL을 local target 기준으로 재구성 + u, err := url.Parse(preq.URL) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + u.Scheme = "http" + u.Host = p.LocalTarget + + req, err := http.NewRequestWithContext(ctx, preq.Method, u.String(), nil) + if err != nil { + return fmt.Errorf("create http request: %w", err) + } + // Body 설정 (원본 바이트를 그대로 사용) + if len(preq.Body) > 0 { + buf := bytes.NewReader(preq.Body) + req.Body = io.NopCloser(buf) + req.ContentLength = int64(len(preq.Body)) + } + // 헤더 복사 + for k, vs := range preq.Header { + for _, v := range vs { + req.Header.Add(k, v) + } + } + + res, err := p.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("perform http request: %w", err) + } + defer res.Body.Close() + + presp.Status = res.StatusCode + for k, vs := range res.Header { + presp.Header[k] = append([]string(nil), vs...) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read http response body: %w", err) + } + presp.Body = body + return nil }