diff --git a/cmd/server/main.go b/cmd/server/main.go index cd3e50c..392b9f7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,14 +9,18 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" + "github.com/prometheus/client_golang/prometheus/promhttp" + "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/observability" "github.com/dalbodeule/hop-gate/internal/protocol" "github.com/dalbodeule/hop-gate/internal/store" ) @@ -126,6 +130,45 @@ var ( sessionsByDomain = make(map[string]*dtlsSessionWrapper) ) +// statusRecorder 는 HTTP 응답 상태 코드를 캡처하기 위한 래퍼입니다. +// Prometheus 메트릭에서 status 라벨을 기록하는 데 사용합니다. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (w *statusRecorder) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +// hostDomainHandler 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 요청을 허용하는 래퍼입니다. +// Host 헤더에서 포트를 제거한 뒤 소문자 비교를 수행합니다. +func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Handler) http.Handler { + allowed := strings.ToLower(strings.TrimSpace(allowedDomain)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if allowed != "" { + host := r.Host + if i := strings.Index(host, ":"); i != -1 { + host = host[:i] + } + host = strings.ToLower(strings.TrimSpace(host)) + if host != allowed { + logger.Warn("rejecting request due to mismatched host", logging.Fields{ + "allowed_domain": allowed, + "request_host": host, + "path": r.URL.Path, + }) + // 메트릭/관리용 엔드포인트에 대해 호스트가 다르면 404 로 응답하여 노출을 최소화합니다. + http.NotFound(w, r) + return + } + } + next.ServeHTTP(w, r) + }) +} + func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) { d := strings.ToLower(strings.TrimSpace(domain)) if d == "" { @@ -163,20 +206,37 @@ func newHTTPHandler(logger logging.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() + method := r.Method + + // 상태 코드 캡처를 위한 래퍼 + sr := &statusRecorder{ + ResponseWriter: w, + status: http.StatusOK, + } + log := logger.With(logging.Fields{ "component": "http_entry", - "method": r.Method, + "method": method, "url": r.URL.String(), "host": r.Host, }) log.Info("incoming http request", nil) + // 요청 단위 Prometheus 메트릭 기록 + defer func() { + elapsed := time.Since(start).Seconds() + statusCode := sr.status + observability.HTTPRequestsTotal.WithLabelValues(method, strconv.Itoa(statusCode)).Inc() + observability.HTTPRequestDurationSeconds.WithLabelValues(method).Observe(elapsed) + }() + // 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) + observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc() + http.Error(sr, "invalid acme challenge path", http.StatusBadRequest) return } filePath := filepath.Join(webroot, token) @@ -195,17 +255,19 @@ func newHTTPHandler(logger logging.Logger) http.Handler { log.Error("failed to open acme challenge file", logging.Fields{ "error": err.Error(), }) - http.NotFound(w, r) + observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc() + http.NotFound(sr, r) return } defer f.Close() // ACME challenge 응답은 일반적으로 text/plain. - w.Header().Set("Content-Type", "text/plain") - if _, err := io.Copy(w, f); err != nil { + sr.Header().Set("Content-Type", "text/plain") + if _, err := io.Copy(sr, f); err != nil { log.Error("failed to write acme challenge response", logging.Fields{ "error": err.Error(), }) + observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc() } return } @@ -219,7 +281,8 @@ func newHTTPHandler(logger logging.Logger) http.Handler { log.Warn("no dtls session for host", logging.Fields{ "host": r.Host, }) - http.Error(w, "no backend client available", http.StatusBadGateway) + observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc() + http.Error(sr, "no backend client available", http.StatusBadGateway) return } @@ -232,22 +295,23 @@ func newHTTPHandler(logger logging.Logger) http.Handler { log.Error("forward over dtls failed", logging.Fields{ "error": err.Error(), }) - http.Error(w, "dtls forward failed", http.StatusBadGateway) + observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_failed").Inc() + http.Error(sr, "dtls forward failed", http.StatusBadGateway) return } // 응답 헤더/바디 복원 for k, vs := range protoResp.Header { for _, v := range vs { - w.Header().Add(k, v) + sr.Header().Add(k, v) } } if protoResp.Status == 0 { protoResp.Status = http.StatusOK } - w.WriteHeader(protoResp.Status) + sr.WriteHeader(protoResp.Status) if len(protoResp.Body) > 0 { - if _, err := w.Write(protoResp.Body); err != nil { + if _, err := sr.Write(protoResp.Body); err != nil { log.Warn("failed to write http response body", logging.Fields{ "error": err.Error(), }) @@ -265,6 +329,9 @@ func newHTTPHandler(logger logging.Logger) http.Handler { func main() { logger := logging.NewStdJSONLogger("server") + // Prometheus 메트릭 등록 + observability.MustRegister() + // 1. 서버 설정 로드 (.env + 환경변수) cfg, err := config.LoadServerConfigFromEnv() if err != nil { @@ -396,10 +463,19 @@ func main() { // 5. HTTP / HTTPS 서버 시작 httpHandler := newHTTPHandler(logger) + // Prometheus /metrics 엔드포인트 및 메인 핸들러를 위한 mux 구성 + httpMux := http.NewServeMux() + allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain)) + + // /metrics 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 접근 가능하도록 제한합니다. + // Admin Plane HTTP mux 도 이후 hostDomainHandler 를 통해 동일하게 제한해야 합니다. + httpMux.Handle("/metrics", hostDomainHandler(allowedDomain, logger, promhttp.Handler())) + httpMux.Handle("/", httpHandler) + // HTTP: 평문 포트 httpSrv := &http.Server{ Addr: cfg.HTTPListen, - Handler: httpHandler, + Handler: httpMux, } go func() { logger.Info("http server listening", logging.Fields{ @@ -415,7 +491,7 @@ func main() { // HTTPS: ACME 기반 TLS 사용 (debug 모드에서도 ACME tls config 사용 가능) httpsSrv := &http.Server{ Addr: cfg.HTTPSListen, - Handler: httpHandler, + Handler: httpMux, TLSConfig: acmeTLSCfg, } go func() { @@ -435,7 +511,7 @@ func main() { } // DTLS 핸드셰이크 단계에서 HOP_SERVER_DOMAIN 으로 설정된 도메인만 허용하도록 래핑합니다. - allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain)) + allowedDomain = strings.ToLower(strings.TrimSpace(cfg.Domain)) var validator dtls.DomainValidator = &domainGateValidator{ allowed: allowedDomain, inner: baseValidator, @@ -458,6 +534,9 @@ func main() { // 세션 종료/타임아웃 관리는 별도의 세션 매니저(TODO)에서 담당해야 합니다. hsRes, err := dtls.PerformServerHandshake(ctx, s, validator, logger) if err != nil { + // 핸드셰이크 실패 메트릭 기록 + observability.DTLSHandshakesTotal.WithLabelValues("failure").Inc() + // PerformServerHandshake 내부에서 이미 상세 로그를 남기므로 여기서는 요약만 기록합니다. logger.Warn("dtls handshake failed", logging.Fields{ "session_id": s.ID(), @@ -469,6 +548,9 @@ func main() { return } + // Handshake 성공 메트릭 기록 + observability.DTLSHandshakesTotal.WithLabelValues("success").Inc() + // Handshake 성공: 서버 측은 어떤 도메인이 연결되었는지 알 수 있습니다. logger.Info("dtls handshake completed", logging.Fields{ "session_id": s.ID(), diff --git a/go.mod b/go.mod index 8621ae9..d6ee597 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/pion/dtls/v3 v3.0.7 + github.com/prometheus/client_golang v1.19.0 golang.org/x/net v0.47.0 ) @@ -15,8 +16,10 @@ require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // 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.7.0 // indirect @@ -25,13 +28,17 @@ require ( 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 + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - golang.org/x/crypto v0.44.0 // indirect + golang.org/x/crypto v0.45.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 + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index 4646b1f..c33bce5 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,14 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= @@ -28,8 +32,8 @@ 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= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -50,6 +54,14 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 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/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 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= @@ -62,6 +74,8 @@ github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWB github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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= @@ -74,5 +88,7 @@ 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= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..8a9ae64 --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,58 @@ +package observability + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// 전역 레지스트리에 등록할 HopGate 메트릭들을 정의합니다. +// Prometheus 기본 네임스페이스를 사용하며, 메트릭 이름에 hopgate_ 접두어를 붙입니다. + +var ( + // DTLS 핸드셰이크 총 횟수 (성공/실패 라벨 포함). + DTLSHandshakesTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "hopgate_dtls_handshakes_total", + Help: "Total number of DTLS handshakes, labeled by result.", + }, + []string{"result"}, // success, failure + ) + + // HTTP/Proxy 엔드포인트를 통해 들어온 요청 수 (메서드/상태 코드 라벨 포함). + HTTPRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "hopgate_http_requests_total", + Help: "Total number of HTTP requests handled by the proxy entrypoint, labeled by method and status code.", + }, + []string{"method", "status"}, + ) + + // HTTP 요청 처리 시간 분포 (메서드 라벨 포함). + HTTPRequestDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "hopgate_http_request_duration_seconds", + Help: "Histogram of HTTP request latencies in seconds at the proxy entrypoint, labeled by method.", + Buckets: prometheus.DefBuckets, + }, + []string{"method"}, + ) + + // Proxy 에러 카운터 (에러 유형 라벨 포함). + ProxyErrorsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "hopgate_proxy_errors_total", + Help: "Total number of proxy-related errors, labeled by error type.", + }, + []string{"type"}, // e.g. no_dtls_session, dtls_forward_failed, acme_http01_error + ) +) + +// MustRegister 는 위에서 정의한 메트릭들을 전역 Prometheus 레지스트리에 등록합니다. +// 서버 시작 시 한 번만 호출해야 합니다. +func MustRegister() { + prometheus.MustRegister( + DTLSHandshakesTotal, + HTTPRequestsTotal, + HTTPRequestDurationSeconds, + ProxyErrorsTotal, + ) +} diff --git a/progress.md b/progress.md index 36095eb..75f4dcd 100644 --- a/progress.md +++ b/progress.md @@ -32,7 +32,7 @@ This document tracks implementation progress against the HopGate architecture an - `[type] short description [BREAK]` 형식, 타입 우선순위 정의, BREAK 규칙. (ko/en) - Defines commit message format, type priorities, and `[BREAK]` convention. (en) -- 아키텍처 그림용 프롬프트: [`arkitecture.prompt`](arkitecture.prompt) +- 아키텍처 그림용 프롬프트: [`architecture.prompt`](images/architecture.prompt) - 외부 도구(예: 나노바나나 Pro)가 참조할 상세 다이어그램 지침. (en 설명 위주) --- @@ -213,20 +213,23 @@ This document tracks implementation progress against the HopGate architecture an - [x] 서버 main 에 ACME 기반 `*tls.Config` 주입 - DTLS / HTTPS 리스너에 ACME 인증서 적용 (Debug 모드에서는 DTLS 에 self-signed, HTTPS 에 ACME 사용). -- [ ] TLS-ALPN-01 챌린지 및 운영 전략 보완 - - 필요 시 TLS-ALPN-01 챌린지 처리 로직 추가. - - 운영/테스트 환경 분리에 대한 문서화 및 구성 정교화. +- [ ] ACME 고급 기능 및 운영 전략 보완 + - TLS-ALPN-01 챌린지 지원 여부 검토 및 필요 시 lego 설정/핸들러 추가. + - 인증서 발급/갱신 실패 시 재시도/백오프 및 경고 로그/알림을 포함한 에러 처리 전략 정의. + - Debug(스테이징 CA) / Production(실 CA) 환경 전환 플로우와 도메인/환경별 ACME 설정 매트릭스를 문서화. --- ### 3.5 Observability / 관측성 -- [ ] Prometheus 메트릭 노출 - - `/metrics` 엔드포인트 추가. - - DTLS 세션 수, 요청 수, 에러 수 등 카운터/게이지 정의. +- [ ] Prometheus 메트릭 노출 및 서버 wiring + - `cmd/server/main.go` 에 Prometheus `/metrics` 엔드포인트 추가 (예: promhttp.Handler). + - DTLS 세션 수, DTLS 핸드셰이크 성공/실패 수, HTTP/Proxy 요청 수 및 에러 수에 대한 카운터/게이지 메트릭 정의. + - 도메인, 클라이언트 ID, request_id 등의 라벨 설계 및 현재 구조적 로깅 필드와 일관성 유지. -- [ ] Loki/Grafana 대시보드 템플릿 초안 작성 - - 주요 필터(도메인, 클라이언트 ID, request_id) 기준 쿼리 예시. +- [ ] Loki/Grafana 대시보드 및 쿼리 예시 + - Loki/Promtail 구성을 가정한 주요 로그 쿼리 예시 정리(도메인, 클라이언트 ID, request_id 기준). + - Prometheus 메트릭 기반 기본 대시보드 템플릿 작성 (DTLS 상태, 프록시 트래픽, 에러율 등). ---