diff --git a/Makefile b/Makefile index f9afeea..6bebed3 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,10 @@ CLIENT_BIN := $(BIN_DIR)/hop-gate-client VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev) +# .env 파일 로드 +include .env +export $(shell sed 's/=.*//' .env) + .PHONY: all server client clean docker-server run-server run-client errors-css all: server client @@ -38,13 +42,13 @@ errors-css: echo "package.json not found; skipping errors-css build"; \ fi -server: errors-css +server: errors-css check-env-server @echo "Building server..." @mkdir -p $(BIN_DIR) $(GO) build -ldflags "-X main.version=$(VERSION)" -o $(SERVER_BIN) $(SERVER_PKG) @echo "Server binary: $(SERVER_BIN)" -client: +client: check-env-client @echo "Building client..." @mkdir -p $(BIN_DIR) $(GO) build -ldflags "-X main.version=$(VERSION)" -o $(CLIENT_BIN) $(CLIENT_PKG) @@ -66,3 +70,15 @@ docker-server: @echo "Building server Docker image..." docker build -f Dockerfile.server -t hop-gate-server:$(VERSION) . +check-env-server: + @if [ -z "$$HOP_SERVER_HTTP_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTP_LISTEN이 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_SERVER_HTTPS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_HTTPS_LISTEN가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_SERVER_DTLS_LISTEN" ]; then echo "필수 환경 변수 HOP_SERVER_DTLS_LISTEN가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_SERVER_DOMAIN" ]; then echo "필수 환경 변수 HOP_SERVER_DOMAIN가 설정되지 않았습니다."; exit 1; fi + +check-env-client: + @if [ -z "$$HOP_CLIENT_SERVER_ADDR" ]; then echo "필수 환경 변수 HOP_CLIENT_SERVER_ADDR가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_CLIENT_DOMAIN" ]; then echo "필수 환경 변수 HOP_CLIENT_DOMAIN가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_CLIENT_API_KEY" ]; then echo "필수 환경 변수 HOP_CLIENT_API_KEY가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_CLIENT_LOCAL_TARGET" ]; then echo "필수 환경 변수 HOP_CLIENT_LOCAL_TARGET가 설정되지 않았습니다."; exit 1; fi + @if [ -z "$$HOP_CLIENT_DEBUG" ]; then echo "필수 환경 변수 HOP_CLIENT_DEBUG가 설정되지 않았습니다."; exit 1; fi \ No newline at end of file diff --git a/README.md b/README.md index 8db0e04..04b5775 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ go mod tidy ### 3.2 Makefile 사용 (Using Makefile) -서버/클라이언트 빌드를 위해 상위 [`Makefile`](Makefile)을 제공합니다. +서버/클라이언트 빌드를 위해 상위 [`Makefile`](Makefile)을 제공합니다. A top-level [`Makefile`](Makefile) is provided for server/client builds. ```bash @@ -69,11 +69,54 @@ make server make client ``` -빌드 결과는 `./bin/hop-gate-server`, `./bin/hop-gate-client` 로 생성됩니다. +빌드 결과는 `./bin/hop-gate-server`, `./bin/hop-gate-client` 로 생성됩니다. Build artifacts are created as `./bin/hop-gate-server` and `./bin/hop-gate-client`. --- +### 3.3 환경변수와 .env 처리 (Environment variables and .env handling) + +HopGate 는 공통 설정을 [`internal/config/config.go`](internal/config/config.go) 에서 로드하며, +**운영체제 환경변수(OS env)가 `.env` 파일보다 우선**하도록 설계되어 있습니다. +HopGate loads shared configuration from [`internal/config/config.go`](internal/config/config.go) and is designed so that **OS-level environment variables take precedence over `.env`**. + +- `.env` 로더: [`loadDotEnvOnce`](internal/config/config.go) + - 현재 작업 디렉터리의 `.env` 파일을 한 번만 읽습니다. + - 이미 OS 환경변수에 설정된 키는 **덮어쓰지 않고 그대로 유지**하고, 비어 있는 키에 대해서만 `.env` 값을 주입합니다. + - `.env` 파일이 존재하지 않으면 조용히 무시합니다 (에러가 아닙니다). + The loader reads the `.env` file once, **does not override existing OS env values**, and only fills missing keys. If `.env` is missing, it is silently ignored. + +- 서버 설정 로더 (Server config loader): [`LoadServerConfigFromEnv`](internal/config/config.go) + - `.env` 로더를 먼저 호출한 뒤, `HOP_SERVER_*` 환경변수에서 서버 설정을 구성합니다. + - 실제 실행 시점에는 서버 엔트리포인트 [`cmd/server/main.go`](cmd/server/main.go) 에서 필수 환경변수가 모두 설정되었는지 한 번 더 검증합니다. + It calls the `.env` loader first, then builds server config from `HOP_SERVER_*` env vars, and finally the server entrypoint [`cmd/server/main.go`](cmd/server/main.go) validates required variables. + +- 클라이언트 설정 로더 (Client config loader): [`LoadClientConfigFromEnv`](internal/config/config.go) + - `.env` 로더를 동일하게 사용하며, `HOP_CLIENT_*` 환경변수에서 클라이언트 설정을 구성합니다. + - 이후 CLI 인자(예: `--server-addr`, `--domain`)가 있을 경우 env 값보다 우선 적용됩니다. + The same loader is used for `HOP_CLIENT_*` env vars, and CLI flags override env values when provided. + +빌드/실행 시 필수 환경변수는 다음 두 단계에서 검증됩니다. +Required environment variables are validated in two stages: + +1. **빌드 단계 (Build-time) – Makefile 체크 (optional guard)** + - [`Makefile`](Makefile) 에서 `.env` 를 `include` 한 뒤, `check-env-server` / `check-env-client` 타깃으로 최소한의 필수 env 를 확인합니다. + - 예) 서버 빌드 시: `make server` → `errors-css` → `check-env-server` → `go build` 순으로 실행됩니다. + The [`Makefile`](Makefile) includes `.env` and uses `check-env-server` / `check-env-client` targets to guard required variables before build. + +2. **실행 단계 (Runtime) – 엔트리포인트에서 엄격 검증 (strict runtime validation)** + - 서버: [`cmd/server/main.go`](cmd/server/main.go) + - 헬퍼 `getEnvOrPanic(logger, key)` 를 사용해 `HOP_SERVER_HTTP_LISTEN`, `HOP_SERVER_HTTPS_LISTEN`, `HOP_SERVER_DTLS_LISTEN`, `HOP_SERVER_DOMAIN`, `HOP_SERVER_DEBUG` 가 비어 있지 않은지 확인합니다. + - 누락되었거나 공백인 경우, 구조화 에러 로그(JSON)와 함께 프로세스를 종료합니다. + - 클라이언트: [`cmd/client/main.go`](cmd/client/main.go) + - `HOP_CLIENT_SERVER_ADDR`, `HOP_CLIENT_DOMAIN`, `HOP_CLIENT_API_KEY`, `HOP_CLIENT_LOCAL_TARGET`, `HOP_CLIENT_DEBUG` 를 동일한 방식으로 검증합니다. + - 두 경우 모두 `HOP_*_DEBUG` 값은 문자열 `"true"` 또는 `"false"` 만 허용합니다. + Both server and client use a helper (`getEnvOrPanic`) to enforce non-empty required env vars at startup and log structured JSON errors on failure. The debug flags must be the strings `"true"` or `"false"`. + +실제 배포 환경에서는 `.env` 보다는 시스템 환경변수(Kubernetes `env`, Docker `-e`, systemd `Environment=` 등)를 사용하는 것을 권장하며, +로컬 개발에서는 `.env.example` 을 복사한 `.env` 파일을 사용해 빠르게 설정을 구성할 수 있습니다. +For production deployments, prefer OS-level env (Kubernetes `env`, Docker `-e`, systemd `Environment=`, etc.), and use a local `.env` (copied from `.env.example`) mainly for development. + ## 4. DTLS 핸드셰이크 테스트 (Testing DTLS Handshake) HopGate는 DTLS 위에서 **도메인 + 클라이언트 API Key** 기반의 애플리케이션 레벨 핸드셰이크를 수행합니다. diff --git a/cmd/client/main.go b/cmd/client/main.go index 86e2b2a..daa5ce5 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -15,6 +15,17 @@ import ( "github.com/dalbodeule/hop-gate/internal/proxy" ) +func getEnvOrPanic(logger logging.Logger, key string) string { + value, exists := os.LookupEnv(key) + if !exists || strings.TrimSpace(value) == "" { + logger.Error("missing required environment variable", logging.Fields{ + "env": key, + }) + os.Exit(1) + } + return value +} + // maskAPIKey 는 로그에 노출할 때 클라이언트 API Key 를 일부만 보여주기 위한 헬퍼입니다. func maskAPIKey(key string) string { if len(key) <= 8 { @@ -36,15 +47,8 @@ func firstNonEmpty(values ...string) string { 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 포함)에서 클라이언트 설정 로드 + // internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다. envCfg, err := config.LoadClientConfigFromEnv() if err != nil { logger.Error("failed to load client config from env", logging.Fields{ @@ -53,6 +57,39 @@ func main() { os.Exit(1) } + // 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선) + serverAddrEnv := getEnvOrPanic(logger, "HOP_CLIENT_SERVER_ADDR") + clientDomainEnv := getEnvOrPanic(logger, "HOP_CLIENT_DOMAIN") + apiKeyEnv := getEnvOrPanic(logger, "HOP_CLIENT_API_KEY") + localTargetEnv := getEnvOrPanic(logger, "HOP_CLIENT_LOCAL_TARGET") + debugEnv := getEnvOrPanic(logger, "HOP_CLIENT_DEBUG") + + // 디버깅 플래그 형식 확인 + if debugEnv != "true" && debugEnv != "false" { + logger.Error("invalid value for HOP_CLIENT_DEBUG; must be 'true' or 'false'", logging.Fields{ + "env": "HOP_CLIENT_DEBUG", + "value": debugEnv, + }) + os.Exit(1) + } + + // 유효성 검사 결과를 구조화 로그로 출력 + logger.Info("validated client env vars", logging.Fields{ + "HOP_CLIENT_SERVER_ADDR": serverAddrEnv, + "HOP_CLIENT_DOMAIN": clientDomainEnv, + "HOP_CLIENT_API_KEY_MASK": maskAPIKey(apiKeyEnv), + "HOP_CLIENT_LOCAL_TARGET": localTargetEnv, + "HOP_CLIENT_DEBUG": debugEnv, + }) + + // 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() + // 2. CLI 인자 우선, env 후순위로 최종 설정 구성 finalCfg := &config.ClientConfig{ ServerAddr: firstNonEmpty(strings.TrimSpace(*serverAddrFlag), strings.TrimSpace(envCfg.ServerAddr)), diff --git a/cmd/server/main.go b/cmd/server/main.go index 356cbeb..e43af50 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -35,6 +35,17 @@ type dtlsSessionWrapper struct { mu sync.Mutex } +func getEnvOrPanic(logger logging.Logger, key string) string { + value, exists := os.LookupEnv(key) + if !exists || strings.TrimSpace(value) == "" { + logger.Error("missing required environment variable", logging.Fields{ + "env": key, + }) + os.Exit(1) + } + return value +} + // canonicalizeDomainForDNS 는 DTLS 핸드셰이크에서 전달된 도메인 문자열을 // DNS 조회 및 DB 조회에 사용할 수 있는 정규화된 호스트명으로 변환합니다. (ko) // canonicalizeDomainForDNS normalizes the domain string from the DTLS handshake @@ -277,8 +288,8 @@ var hopGateOwnedHeaders = map[string]struct{}{ "Referrer-Policy": {}, } -// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/502/504/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko) -// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/502/504/525). (en) +// writeErrorPage 는 주요 HTTP 에러 코드(400/404/500/525)에 대해 정적 HTML 에러 페이지를 렌더링합니다. (ko) +// writeErrorPage renders static HTML error pages for key HTTP error codes (400/404/500/525). (en) // // 템플릿 로딩 우선순위: (ko) // 1. HOP_ERROR_PAGES_DIR/.html (또는 ./errors/.html) (ko) @@ -294,31 +305,9 @@ func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) { setSecurityAndIdentityHeaders(w, r) } - // 4xx / 5xx 대역에 대한 템플릿 매핑 규칙: (ko) - // - 400 series: 400.html 로 렌더링 (단, 404 는 404.html 사용) (ko) - // - 500 series: 500.html 로 렌더링 (단, 502/504/525 는 개별 템플릿 사용) (ko) - // - // Mapping rules for 4xx / 5xx ranges: (en) - // - 400 series: render using 400.html (except 404 uses 404.html). (en) - // - 500 series: render using 500.html (except 502/504/525 which have dedicated templates). (en) - mapped := status - switch { - case status >= 400 && status < 500: - if status != http.StatusBadRequest && status != http.StatusNotFound { - mapped = http.StatusBadRequest - } - case status >= 500 && status < 600: - if status != http.StatusInternalServerError && - status != http.StatusBadGateway && - status != errorpages.StatusGatewayTimeout && - status != errorpages.StatusTLSHandshakeFailed { - mapped = http.StatusInternalServerError - } - } - - // Delegates actual HTML rendering to internal/errorpages with mapped status. (en) - // 실제 HTML 렌더링은 매핑된 상태 코드로 internal/errorpages 패키지에 위임합니다. (ko) - errorpages.Render(w, r, mapped) + // Delegates actual HTML rendering to internal/errorpages. (en) + // 실제 HTML 렌더링은 internal/errorpages 패키지에 위임합니다. (ko) + errorpages.Render(w, r, status) } // setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko) @@ -652,10 +641,8 @@ func newHTTPHandler(logger logging.Logger, proxyTimeout time.Duration) http.Hand func main() { logger := logging.NewStdJSONLogger("server") - // Prometheus 메트릭 등록 - observability.MustRegister() - // 1. 서버 설정 로드 (.env + 환경변수) + // internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다. cfg, err := config.LoadServerConfigFromEnv() if err != nil { logger.Error("failed to load server config from env", logging.Fields{ @@ -664,6 +651,34 @@ func main() { os.Exit(1) } + // 2. 필수 환경 변수 유효성 검사 (.env 포함; OS 환경변수가 우선) + httpListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTP_LISTEN") + httpsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_HTTPS_LISTEN") + dtlsListenEnv := getEnvOrPanic(logger, "HOP_SERVER_DTLS_LISTEN") + domainEnv := getEnvOrPanic(logger, "HOP_SERVER_DOMAIN") + debugEnv := getEnvOrPanic(logger, "HOP_SERVER_DEBUG") + + // 디버깅 플래그 형식 확인 + if debugEnv != "true" && debugEnv != "false" { + logger.Error("invalid value for HOP_SERVER_DEBUG; must be 'true' or 'false'", logging.Fields{ + "env": "HOP_SERVER_DEBUG", + "value": debugEnv, + }) + os.Exit(1) + } + + // 유효성 검사 결과를 구조화 로그로 출력 + logger.Info("validated server env vars", logging.Fields{ + "HOP_SERVER_HTTP_LISTEN": httpListenEnv, + "HOP_SERVER_HTTPS_LISTEN": httpsListenEnv, + "HOP_SERVER_DTLS_LISTEN": dtlsListenEnv, + "HOP_SERVER_DOMAIN": domainEnv, + "HOP_SERVER_DEBUG": debugEnv, + }) + + // Prometheus 메트릭 등록 + observability.MustRegister() + logger.Info("hop-gate server starting", logging.Fields{ "stack": "prometheus-loki-grafana", "http_listen": cfg.HTTPListen, diff --git a/go.sum b/go.sum index cfe1a69..2c9b84c 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= diff --git a/internal/config/config.go b/internal/config/config.go index bba8ec9..7cc776e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -107,7 +107,11 @@ func loadDotEnvOnce() { val = strings.Trim(val, `"'`) if key != "" { - _ = os.Setenv(key, val) + // 이미 OS 환경변수에 설정된 값이 있는 경우 이를 우선시하고, + // 비어 있는 키에 대해서만 .env 값을 주입합니다. + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } } } if err := scanner.Err(); err != nil { @@ -209,7 +213,8 @@ func loadLoggingFromEnv() LoggingConfig { } } -// LoadServerConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 서버 설정을 구성합니다. +// LoadServerConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤 +// "환경변수 > .env" 우선순위로 서버 설정을 구성합니다. func LoadServerConfigFromEnv() (*ServerConfig, error) { loadDotEnvOnce() if dotenvErr != nil { @@ -228,7 +233,8 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) { return cfg, nil } -// LoadClientConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 클라이언트 설정을 구성합니다. +// LoadClientConfigFromEnv 는 .env 를 한 번 읽어 현재 환경변수를 보완한 뒤 +// "환경변수 > .env" 우선순위로 클라이언트 설정을 구성합니다. // 실제 런타임에서 사용되는 필드는 ServerAddr, Domain, ClientAPIKey, LocalTarget 입니다. func LoadClientConfigFromEnv() (*ClientConfig, error) { loadDotEnvOnce()