diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..37a6725 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,59 @@ +name: Build and publish HopGate image to GHCR + +on: + push: + branches: [ main ] + tags: + - 'v*.*.*' + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/hop-gate + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push multi-arch image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.server + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 392b9f7..202c781 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -78,7 +79,7 @@ func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Log // 간단한 RequestID 생성 (실제 서비스에서는 UUID 등을 사용하는 것이 좋음) requestID := time.Now().UTC().Format("20060102T150405.000000000") - protoReq := &protocol.Request{ + httpReq := &protocol.Request{ RequestID: requestID, ClientID: "", // TODO: 클라이언트 식별자 도입 시 채우기 ServiceName: serviceName, @@ -100,29 +101,45 @@ func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Log "scheme": req.URL.Scheme, }) + // HTTP 요청을 Envelope 로 감싸서 전송합니다. + env := &protocol.Envelope{ + Type: protocol.MessageTypeHTTP, + HTTPRequest: httpReq, + } + enc := json.NewEncoder(w.sess) - if err := enc.Encode(protoReq); err != nil { - log.Error("failed to encode protocol request", logging.Fields{ + if err := enc.Encode(env); err != nil { + log.Error("failed to encode http envelope", logging.Fields{ "error": err.Error(), }) return nil, err } - var protoResp protocol.Response + // 클라이언트로부터 HTTP 응답 Envelope 를 수신합니다. + var respEnv protocol.Envelope dec := json.NewDecoder(w.sess) - if err := dec.Decode(&protoResp); err != nil { - log.Error("failed to decode protocol response", logging.Fields{ + if err := dec.Decode(&respEnv); err != nil { + log.Error("failed to decode http envelope", logging.Fields{ "error": err.Error(), }) return nil, err } + if respEnv.Type != protocol.MessageTypeHTTP || respEnv.HTTPResponse == nil { + log.Error("received non-http envelope from client", logging.Fields{ + "type": respEnv.Type, + }) + return nil, fmt.Errorf("unexpected envelope type %q or empty http_response", respEnv.Type) + } + + protoResp := respEnv.HTTPResponse + log.Info("received dtls response", logging.Fields{ "status": protoResp.Status, "error": protoResp.Error, }) - return &protoResp, nil + return protoResp, nil } var ( @@ -142,6 +159,36 @@ func (w *statusRecorder) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) } +// hopGateOwnedHeaders 는 HopGate 서버가 스스로 관리하는 응답 헤더 목록입니다. (ko) +// hopGateOwnedHeaders lists response headers that are owned by the HopGate server. (en) +var hopGateOwnedHeaders = map[string]struct{}{ + "X-HopGate-Server": {}, + "Strict-Transport-Security": {}, + "X-Content-Type-Options": {}, + "Referrer-Policy": {}, +} + +// setSecurityAndIdentityHeaders 는 HopGate 에서 공통으로 추가하는 보안/식별 헤더를 설정합니다. (ko) +// setSecurityAndIdentityHeaders configures common security and identity headers for HopGate. (en) +func setSecurityAndIdentityHeaders(w http.ResponseWriter, r *http.Request) { + h := w.Header() + + // HopGate 로 구성된 서버임을 나타내는 식별 헤더 (ko) + // Header to indicate that this server is powered by HopGate. (en) + h.Set("X-HopGate-Server", "hop-gate") + + // 기본 보안 헤더 설정 (ko) + // Basic security headers (best-effort). (en) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + + // HTTPS 요청에 대해서만 HSTS 헤더를 추가합니다. (ko) + // Only send HSTS for HTTPS requests. (en) + if r != nil && r.TLS != nil { + h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + } +} + // hostDomainHandler 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 요청을 허용하는 래퍼입니다. // Host 헤더에서 포트를 제거한 뒤 소문자 비교를 수행합니다. func hostDomainHandler(allowedDomain string, logger logging.Logger, next http.Handler) http.Handler { @@ -213,6 +260,9 @@ func newHTTPHandler(logger logging.Logger) http.Handler { ResponseWriter: w, status: http.StatusOK, } + // 보안/식별 헤더를 공통으로 설정합니다. (ko) + // Configure common security and identity headers. (en) + setSecurityAndIdentityHeaders(sr, r) log := logger.With(logging.Fields{ "component": "http_entry", @@ -286,6 +336,29 @@ func newHTTPHandler(logger logging.Logger) http.Handler { return } + // 원본 클라이언트 IP를 X-Forwarded-For / X-Real-IP 헤더로 전달합니다. (ko) + // Forward original client IP via X-Forwarded-For / X-Real-IP headers. (en) + if r.RemoteAddr != "" { + remoteIP := r.RemoteAddr + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + remoteIP = ip + } + if remoteIP != "" { + // X-Forwarded-For 는 기존 값 뒤에 원본 IP를 추가합니다. (ko) + // Append original IP to X-Forwarded-For if present. (en) + if prior := r.Header.Get("X-Forwarded-For"); prior == "" { + r.Header.Set("X-Forwarded-For", remoteIP) + } else { + r.Header.Set("X-Forwarded-For", prior+", "+remoteIP) + } + // X-Real-IP 가 비어있는 경우에만 설정합니다. (ko) + // Set X-Real-IP only if it is not already set. (en) + if r.Header.Get("X-Real-IP") == "" { + r.Header.Set("X-Real-IP", remoteIP) + } + } + } + // r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기 defer r.Body.Close() @@ -302,6 +375,11 @@ func newHTTPHandler(logger logging.Logger) http.Handler { // 응답 헤더/바디 복원 for k, vs := range protoResp.Header { + // HopGate 가 소유한 보안/식별 헤더는 백엔드 값 대신 서버 값만 사용합니다. (ko) + // For security/identity headers owned by HopGate, ignore backend values. (en) + if _, ok := hopGateOwnedHeaders[http.CanonicalHeaderKey(k)]; ok { + continue + } for _, v := range vs { sr.Header().Add(k, v) } diff --git a/go.sum b/go.sum index c33bce5..cfe1a69 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ 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= 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= diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index ff0d304..312fe02 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -1,6 +1,7 @@ package protocol // Request 는 서버-클라이언트 간에 전달되는 HTTP 요청을 표현합니다. +// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다. type Request struct { RequestID string ClientID string // 대상 클라이언트 식별자 @@ -13,6 +14,7 @@ type Request struct { } // Response 는 서버-클라이언트 간에 전달되는 HTTP 응답을 표현합니다. +// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다. type Response struct { RequestID string Status int @@ -20,3 +22,68 @@ type Response struct { Body []byte Error string // 에러 발생 시 설명 메시지 } + +// --- 확장 가능 DTLS 메시지 Envelope 및 스트림 구조체 --- +// +// WebSocket/TCP 스트림 터널링을 지원하기 위해, 단일 HTTP 요청/응답 외에도 +// 스트림 기반 메시지를 운반할 수 있는 Envelope 타입을 정의합니다. +// 현재 구현에서는 아직 사용하지 않으며, 향후 단계적으로 적용할 예정입니다. + +// MessageType 은 DTLS 위에서 교환되는 상위 레벨 메시지 종류를 나타냅니다. +type MessageType string + +const ( + // MessageTypeHTTP 는 기존 단일 HTTP 요청/응답 메시지를 의미합니다. + // 이 경우 HTTPRequest / HTTPResponse 필드를 사용합니다. + MessageTypeHTTP MessageType = "http" + + // MessageTypeStreamOpen 은 새로운 스트림(TCP/WebSocket 등)의 오픈을 의미합니다. + MessageTypeStreamOpen MessageType = "stream_open" + + // MessageTypeStreamData 는 열린 스트림에 대한 양방향 데이터 프레임을 의미합니다. + MessageTypeStreamData MessageType = "stream_data" + + // MessageTypeStreamClose 는 스트림 종료(정상/에러)를 의미합니다. + MessageTypeStreamClose MessageType = "stream_close" +) + +// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다. +// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다. +type Envelope struct { + Type MessageType `json:"type"` + + // HTTP 1회성 요청/응답 (기존 터널링 경로) + HTTPRequest *Request `json:"http_request,omitempty"` + HTTPResponse *Response `json:"http_response,omitempty"` + + // 스트림 기반 메시지 (WebSocket/TCP 터널용) + StreamOpen *StreamOpen `json:"stream_open,omitempty"` + StreamData *StreamData `json:"stream_data,omitempty"` + StreamClose *StreamClose `json:"stream_close,omitempty"` +} + +// StreamID 는 스트림(예: 특정 WebSocket 연결 또는 TCP 커넥션)을 구분하기 위한 식별자입니다. +type StreamID string + +// StreamOpen 은 새로운 스트림을 여는 요청을 나타냅니다. +type StreamOpen struct { + ID StreamID `json:"id"` + + // Service / TargetAddr 는 클라이언트 측에서 어느 로컬 서비스로 연결해야 하는지를 나타냅니다. + // 최소 구현에서는 LocalTarget 하나만 사용해도 되며, 추후 서비스별로 확장 가능합니다. + Service string `json:"service_name,omitempty"` + TargetAddr string `json:"target_addr,omitempty"` // 예: "127.0.0.1:8080" + Header map[string][]string `json:"header,omitempty"` // 초기 HTTP 헤더(Upgrade 포함) 전달용 +} + +// StreamData 는 이미 열린 스트림에 대해 한 방향으로 전송되는 데이터 프레임을 표현합니다. +type StreamData struct { + ID StreamID `json:"id"` + Data []byte `json:"data"` +} + +// StreamClose 는 스트림 종료를 알리는 메시지입니다. +type StreamClose struct { + ID StreamID `json:"id"` + Error string `json:"error,omitempty"` // 비워두면 정상 종료로 해석 +} diff --git a/internal/proxy/client.go b/internal/proxy/client.go index a756fe3..0f2f9a1 100644 --- a/internal/proxy/client.go +++ b/internal/proxy/client.go @@ -51,10 +51,10 @@ func NewClientProxy(logger logging.Logger, localTarget string) *ClientProxy { } } -// StartLoop 는 DTLS 세션에서 protocol.Request 를 읽고 로컬 HTTP 요청을 수행한 뒤 -// protocol.Response 를 다시 세션으로 쓰는 루프를 실행합니다. (ko) -// StartLoop reads protocol.Request messages from the DTLS session, performs local HTTP -// requests, and writes back protocol.Response objects. (en) +// StartLoop 는 DTLS 세션에서 protocol.Envelope 를 읽고, HTTP 요청의 경우 로컬 HTTP 요청을 수행한 뒤 +// protocol.Envelope(HTTP 응답 포함)을 다시 세션으로 쓰는 루프를 실행합니다. (ko) +// StartLoop reads protocol.Envelope messages from the DTLS session; for HTTP messages it +// performs local HTTP requests and writes back HTTP responses wrapped in an Envelope. (en) func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { if ctx == nil { ctx = context.Background() @@ -74,18 +74,28 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { default: } - var req protocol.Request - if err := dec.Decode(&req); err != nil { + var env protocol.Envelope + if err := dec.Decode(&env); 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{ + log.Error("failed to decode protocol envelope", logging.Fields{ "error": err.Error(), }) return err } + // 현재는 HTTP 타입만 지원하며, 그 외 타입은 에러로 처리합니다. + if env.Type != protocol.MessageTypeHTTP || env.HTTPRequest == nil { + log.Error("received unsupported envelope type from server", logging.Fields{ + "type": env.Type, + }) + return fmt.Errorf("unsupported envelope type %q or missing http_request", env.Type) + } + + req := env.HTTPRequest + start := time.Now() logReq := log.With(logging.Fields{ "request_id": req.RequestID, @@ -95,7 +105,7 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { "client_id": req.ClientID, "local_target": p.LocalTarget, }) - logReq.Info("received protocol request from server", nil) + logReq.Info("received http envelope from server", nil) resp := protocol.Response{ RequestID: req.RequestID, @@ -103,7 +113,7 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { } // 로컬 HTTP 요청 수행 - if err := p.forwardToLocal(ctx, &req, &resp); err != nil { + 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{ @@ -111,14 +121,20 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error { }) } - if err := enc.Encode(&resp); err != nil { - logReq.Error("failed to encode protocol response", logging.Fields{ + // HTTP 응답을 Envelope 로 감싸서 서버로 전송합니다. + respEnv := protocol.Envelope{ + Type: protocol.MessageTypeHTTP, + HTTPResponse: &resp, + } + + if err := enc.Encode(&respEnv); err != nil { + logReq.Error("failed to encode http response envelope", logging.Fields{ "error": err.Error(), }) return err } - logReq.Info("protocol response sent to server", logging.Fields{ + logReq.Info("http response envelope sent to server", logging.Fields{ "status": resp.Status, "elapsed_ms": time.Since(start).Milliseconds(), "error": resp.Error,