mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-12 06:40:11 +09:00
- Removed session-level serialization lock for HTTP requests (`requestMu`) to support concurrent stream processing. - Introduced centralized `readLoop` and `streamReceiver` design for stream demultiplexing and individual stream handling. - Updated client to handle multiple `StreamOpen/StreamData/StreamClose` per session concurrently with per-stream ARQ state. - Enhanced server logic for efficient HTTP mapping and response streaming in a concurrent stream environment.
1586 lines
54 KiB
Go
1586 lines
54 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"fmt"
|
||
"io"
|
||
stdfs "io/fs"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||
|
||
"github.com/dalbodeule/hop-gate/internal/acme"
|
||
"github.com/dalbodeule/hop-gate/internal/admin"
|
||
"github.com/dalbodeule/hop-gate/internal/config"
|
||
"github.com/dalbodeule/hop-gate/internal/dtls"
|
||
"github.com/dalbodeule/hop-gate/internal/errorpages"
|
||
"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"
|
||
)
|
||
|
||
// version 은 빌드 시 -ldflags "-X main.version=xxxxxxx" 로 덮어쓰이는 필드입니다.
|
||
// 기본값 "dev" 는 로컬 개발용입니다.
|
||
var version = "dev"
|
||
|
||
// pendingRequest tracks a request waiting for its response
|
||
type pendingRequest struct {
|
||
streamID protocol.StreamID
|
||
respCh chan *protocol.Envelope
|
||
doneCh chan struct{}
|
||
}
|
||
|
||
// streamSender 는 특정 스트림에 대해 전송한 StreamData 프레임의 payload 를
|
||
// 시퀀스 번호별로 보관하여, peer 로부터의 StreamAck 를 기반으로 선택적 재전송을
|
||
// 수행하기 위한 송신 측 ARQ 상태를 나타냅니다. (ko)
|
||
// streamSender keeps outstanding StreamData payloads per sequence number so that
|
||
// they can be selectively retransmitted based on StreamAck from the peer. (en)
|
||
type streamSender struct {
|
||
mu sync.Mutex
|
||
outstanding map[uint64][]byte
|
||
}
|
||
|
||
func newStreamSender() *streamSender {
|
||
return &streamSender{
|
||
outstanding: make(map[uint64][]byte),
|
||
}
|
||
}
|
||
|
||
func (s *streamSender) register(seq uint64, data []byte) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if s.outstanding == nil {
|
||
s.outstanding = make(map[uint64][]byte)
|
||
}
|
||
buf := make([]byte, len(data))
|
||
copy(buf, data)
|
||
s.outstanding[seq] = buf
|
||
}
|
||
|
||
// handleAck 는 주어진 StreamAck 를 적용하여 AckSeq 이하의 프레임을 정리하고,
|
||
// LostSeqs 중 아직 outstanding 에 남아 있는 시퀀스의 payload 를 복사하여
|
||
// 재전송 대상 목록으로 반환합니다. (ko)
|
||
// handleAck applies the given StreamAck, removes frames up to AckSeq, and
|
||
// returns copies of payloads for LostSeqs that are still outstanding so that
|
||
// they can be retransmitted. (en)
|
||
func (s *streamSender) handleAck(ack *protocol.StreamAck) map[uint64][]byte {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if s.outstanding == nil {
|
||
return nil
|
||
}
|
||
|
||
// 연속 수신 완료 구간(seq <= AckSeq)은 outstanding 에서 제거합니다.
|
||
for seq := range s.outstanding {
|
||
if seq <= ack.AckSeq {
|
||
delete(s.outstanding, seq)
|
||
}
|
||
}
|
||
|
||
// LostSeqs 가 비어 있으면 재전송할 것이 없습니다.
|
||
if len(ack.LostSeqs) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// LostSeqs 중 아직 outstanding 에 남아 있는 것만 재전송 대상으로 선택합니다.
|
||
lost := make(map[uint64][]byte, len(ack.LostSeqs))
|
||
for _, seq := range ack.LostSeqs {
|
||
if data, ok := s.outstanding[seq]; ok {
|
||
buf := make([]byte, len(data))
|
||
copy(buf, data)
|
||
lost[seq] = buf
|
||
}
|
||
}
|
||
return lost
|
||
}
|
||
|
||
type dtlsSessionWrapper struct {
|
||
sess dtls.Session
|
||
bufferedReader *bufio.Reader
|
||
codec protocol.WireCodec
|
||
logger logging.Logger
|
||
|
||
mu sync.Mutex
|
||
nextStreamID uint64
|
||
pending map[protocol.StreamID]*pendingRequest
|
||
readerDone chan struct{}
|
||
|
||
// streamSenders 는 서버 → 클라이언트 방향 HTTP 요청 바디 전송에 대한
|
||
// 송신 측 ARQ 상태를 보관합니다. (ko)
|
||
// streamSenders keeps ARQ sender state for HTTP request bodies sent
|
||
// from server to client. (en)
|
||
streamSenders map[protocol.StreamID]*streamSender
|
||
}
|
||
|
||
// registerStreamSender 는 주어진 스트림 ID 에 대한 송신 측 ARQ 상태를 등록합니다. (ko)
|
||
// registerStreamSender registers the sender-side ARQ state for a given stream ID. (en)
|
||
func (w *dtlsSessionWrapper) registerStreamSender(id protocol.StreamID, sender *streamSender) {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
|
||
if w.streamSenders == nil {
|
||
w.streamSenders = make(map[protocol.StreamID]*streamSender)
|
||
}
|
||
w.streamSenders[id] = sender
|
||
}
|
||
|
||
// unregisterStreamSender 는 더 이상 사용하지 않는 스트림 ID 에 대한 송신 측 ARQ 상태를 제거합니다. (ko)
|
||
// unregisterStreamSender removes the sender-side ARQ state for a stream ID that is no longer used. (en)
|
||
func (w *dtlsSessionWrapper) unregisterStreamSender(id protocol.StreamID) {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
|
||
if w.streamSenders == nil {
|
||
return
|
||
}
|
||
delete(w.streamSenders, id)
|
||
}
|
||
|
||
// getStreamSender 는 주어진 스트림 ID 에 대한 송신 측 ARQ 상태를 반환합니다. (ko)
|
||
// getStreamSender returns the sender-side ARQ state for the given stream ID, if any. (en)
|
||
func (w *dtlsSessionWrapper) getStreamSender(id protocol.StreamID) *streamSender {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
|
||
if w.streamSenders == nil {
|
||
return nil
|
||
}
|
||
return w.streamSenders[id]
|
||
}
|
||
|
||
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
|
||
// into a host name suitable for DNS and DB lookups. (en)
|
||
func canonicalizeDomainForDNS(raw string) string {
|
||
d := strings.TrimSpace(raw)
|
||
if d == "" {
|
||
return ""
|
||
}
|
||
// "host:port" 형태가 들어온 경우 포트를 제거합니다. (ko)
|
||
// Strip port if the value is in "host:port" form. (en)
|
||
if h, _, err := net.SplitHostPort(d); err == nil && strings.TrimSpace(h) != "" {
|
||
d = h
|
||
}
|
||
return strings.ToLower(d)
|
||
}
|
||
|
||
// domainGateValidator 는 DTLS 핸드셰이크 시 도메인이 EXPECT_IPS(HOP_ACME_EXPECT_IPS)에
|
||
// 설정된 IP(IPv4/IPv6)로 해석되는지 검사한 뒤, 내부 DomainValidator 로 위임합니다. (ko)
|
||
// domainGateValidator first checks that the domain resolves to one of the
|
||
// expected IPs (from HOP_ACME_EXPECT_IPS), then delegates to the inner
|
||
// DomainValidator for (domain, client_api_key) validation. (en)
|
||
type domainGateValidator struct {
|
||
expectedIPs []net.IP
|
||
inner dtls.DomainValidator
|
||
logger logging.Logger
|
||
}
|
||
|
||
func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error {
|
||
d := canonicalizeDomainForDNS(domain)
|
||
if d == "" {
|
||
return fmt.Errorf("empty domain is not allowed for dtls handshake")
|
||
}
|
||
|
||
// EXPECT_IPS(HOP_ACME_EXPECT_IPS)가 설정된 경우, 도메인이 해당 IP(IPv4/IPv6)들로
|
||
// 해석되는지 DNS(A/AAAA) 조회를 통해 검증합니다. (ko)
|
||
// If EXPECT_IPS (HOP_ACME_EXPECT_IPS) is configured, ensure that the domain
|
||
// resolves (via A/AAAA) to at least one of the expected IPs. (en)
|
||
if len(v.expectedIPs) > 0 {
|
||
resolver := net.DefaultResolver
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
ips, err := resolver.LookupIP(ctx, "ip", d)
|
||
if err != nil {
|
||
if v.logger != nil {
|
||
v.logger.Warn("dtls handshake dns resolution failed", logging.Fields{
|
||
"domain": d,
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
return fmt.Errorf("dns resolution failed for %s: %w", d, err)
|
||
}
|
||
|
||
match := false
|
||
for _, ip := range ips {
|
||
for _, expected := range v.expectedIPs {
|
||
if ip.Equal(expected) {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if match {
|
||
break
|
||
}
|
||
}
|
||
|
||
if !match {
|
||
if v.logger != nil {
|
||
v.logger.Warn("dtls handshake rejected due to unexpected resolved IPs", logging.Fields{
|
||
"domain": d,
|
||
"resolved_ips": ips,
|
||
"expected_ips": v.expectedIPs,
|
||
})
|
||
}
|
||
return fmt.Errorf("domain %s does not resolve to any expected IPs", d)
|
||
}
|
||
}
|
||
|
||
if v.inner != nil {
|
||
return v.inner.ValidateDomainAPIKey(ctx, d, clientAPIKey)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseExpectedIPsFromEnv 는 HOP_ACME_EXPECT_IPS 와 같이 콤마로 구분된 IP 목록
|
||
// 환경변수를 파싱해 net.IP 슬라이스로 변환합니다. IPv4/IPv6 모두 지원합니다. (ko)
|
||
// parseExpectedIPsFromEnv parses a comma-separated list of IPs from env (e.g. HOP_ACME_EXPECT_IPS)
|
||
// into a slice of net.IP, supporting both IPv4 and IPv6 literals. (en)
|
||
func parseExpectedIPsFromEnv(logger logging.Logger, envKey string) []net.IP {
|
||
raw := strings.TrimSpace(os.Getenv(envKey))
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(raw, ",")
|
||
var result []net.IP
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
continue
|
||
}
|
||
ip := net.ParseIP(p)
|
||
if ip == nil {
|
||
if logger != nil {
|
||
logger.Warn("invalid ip in env, skipping", logging.Fields{
|
||
"env": envKey,
|
||
"value": p,
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
result = append(result, ip)
|
||
}
|
||
if logger != nil {
|
||
logger.Info("loaded expected handshake ips from env", logging.Fields{
|
||
"env": envKey,
|
||
"ips": result,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ForwardHTTP 는 HTTP 요청을 DTLS 세션 위의 StreamOpen/StreamData/StreamClose 프레임으로 전송하고,
|
||
// 역방향 스트림 응답을 수신해 protocol.Response 로 반환합니다. (ko)
|
||
// readLoop continuously reads from the DTLS session and dispatches incoming frames
|
||
// to the appropriate pending request based on stream ID. It also handles
|
||
// application-level ARQ (StreamAck) for request bodies sent from server to client. (en)
|
||
func (w *dtlsSessionWrapper) readLoop() {
|
||
defer close(w.readerDone)
|
||
|
||
for {
|
||
var env protocol.Envelope
|
||
if err := w.codec.Decode(w.bufferedReader, &env); err != nil {
|
||
if err == io.EOF {
|
||
w.logger.Info("dtls session closed", nil)
|
||
} else {
|
||
w.logger.Error("failed to decode envelope in read loop", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
// Notify all pending requests of the error by closing their response channels.
|
||
// The doneCh will be closed by each ForwardHTTP's defer.
|
||
w.mu.Lock()
|
||
for _, pending := range w.pending {
|
||
close(pending.respCh)
|
||
}
|
||
w.pending = make(map[protocol.StreamID]*pendingRequest)
|
||
w.mu.Unlock()
|
||
return
|
||
}
|
||
|
||
// 1) StreamAck 처리: 서버 → 클라이언트 방향 요청 바디 전송에 대한 ARQ. (ko)
|
||
// 1) Handle StreamAck: application-level ARQ for request bodies
|
||
// sent from server to client. (en)
|
||
if env.Type == protocol.MessageTypeStreamAck {
|
||
sa := env.StreamAck
|
||
if sa == nil {
|
||
w.logger.Warn("received stream_ack envelope with nil payload", logging.Fields{})
|
||
continue
|
||
}
|
||
streamID := sa.ID
|
||
sender := w.getStreamSender(streamID)
|
||
if sender == nil {
|
||
w.logger.Warn("received stream_ack for unknown stream ID", logging.Fields{
|
||
"stream_id": streamID,
|
||
})
|
||
continue
|
||
}
|
||
lost := sender.handleAck(sa)
|
||
for seq, data := range lost {
|
||
retryEnv := protocol.Envelope{
|
||
Type: protocol.MessageTypeStreamData,
|
||
StreamData: &protocol.StreamData{
|
||
ID: streamID,
|
||
Seq: seq,
|
||
Data: data,
|
||
},
|
||
}
|
||
if err := w.codec.Encode(w.sess, &retryEnv); err != nil {
|
||
w.logger.Error("failed to retransmit stream_data after stream_ack", logging.Fields{
|
||
"stream_id": streamID,
|
||
"seq": seq,
|
||
"error": err.Error(),
|
||
})
|
||
// 세션 쓰기 오류가 발생하면 루프를 종료하여 상위에서 세션 종료를 유도합니다. (ko)
|
||
// On write error, stop the loop so that the caller can tear down the session. (en)
|
||
return
|
||
}
|
||
}
|
||
// StreamAck 는 애플리케이션 페이로드를 포함하지 않으므로 pending 에 전달하지 않습니다. (ko)
|
||
// StreamAck carries no application payload, so it is not forwarded to pending requests. (en)
|
||
continue
|
||
}
|
||
|
||
// 2) StreamOpen / StreamData / StreamClose 에 대해 stream ID 를 산출하고,
|
||
// 해당 pending 요청으로 전달합니다. (ko)
|
||
// 2) For StreamOpen / StreamData / StreamClose, determine the stream ID
|
||
// and forward to the corresponding pending request. (en)
|
||
var streamID protocol.StreamID
|
||
switch env.Type {
|
||
case protocol.MessageTypeStreamOpen:
|
||
if env.StreamOpen != nil {
|
||
streamID = env.StreamOpen.ID
|
||
}
|
||
case protocol.MessageTypeStreamData:
|
||
if env.StreamData != nil {
|
||
streamID = env.StreamData.ID
|
||
}
|
||
case protocol.MessageTypeStreamClose:
|
||
if env.StreamClose != nil {
|
||
streamID = env.StreamClose.ID
|
||
}
|
||
default:
|
||
w.logger.Warn("received unexpected envelope type in read loop", logging.Fields{
|
||
"type": env.Type,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if streamID == "" {
|
||
w.logger.Warn("received envelope with empty stream ID", logging.Fields{
|
||
"type": env.Type,
|
||
})
|
||
continue
|
||
}
|
||
|
||
// Find the pending request for this stream ID
|
||
w.mu.Lock()
|
||
pending := w.pending[streamID]
|
||
w.mu.Unlock()
|
||
|
||
if pending == nil {
|
||
w.logger.Warn("received envelope for unknown stream ID", logging.Fields{
|
||
"stream_id": streamID,
|
||
"type": env.Type,
|
||
})
|
||
continue
|
||
}
|
||
|
||
// Send the envelope to the waiting request
|
||
select {
|
||
case pending.respCh <- &env:
|
||
// Successfully delivered
|
||
case <-pending.doneCh:
|
||
// Request was cancelled or timed out
|
||
w.logger.Warn("pending request already closed", logging.Fields{
|
||
"stream_id": streamID,
|
||
})
|
||
default:
|
||
// Channel buffer full - shouldn't happen with proper sizing
|
||
w.logger.Warn("response channel buffer full, dropping frame", logging.Fields{
|
||
"stream_id": streamID,
|
||
"type": env.Type,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ForwardHTTP forwards an HTTP request over the DTLS session using StreamOpen/StreamData/StreamClose
|
||
// frames and reconstructs the reverse stream into a protocol.Response. (en)
|
||
// This method now supports concurrent requests by using a channel-based multiplexing approach.
|
||
func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
// 클라이언트는 단일 DTLS 세션 내에서 다중 HTTP 스트림을 처리할 수 있도록
|
||
// 중앙 readLoop + per-stream demux 구조(3.3B.1~3.3B.2)가 적용되어 있습니다. (ko)
|
||
// With the client-side central read loop + per-stream demux (3.3B.1–3.3B.2),
|
||
// a single DTLS session can now handle multiple concurrent HTTP streams. (en)
|
||
//
|
||
// 3.3B.4에서 정의한 것처럼, 서버 측에서는 더 이상 세션 단위 직렬화 락을 사용하지 않고
|
||
// 동일 DTLS 세션 위에서 여러 ForwardHTTP 호출이 서로 다른 StreamID 로 병렬 진행되도록
|
||
// 허용합니다. (ko)
|
||
// As per 3.3B.4, we no longer use a session-level serialization lock here and
|
||
// allow multiple ForwardHTTP calls to run concurrently on the same DTLS session
|
||
// using distinct StreamIDs. (en)
|
||
|
||
// Generate a unique stream ID (needs mutex for nextStreamID)
|
||
w.mu.Lock()
|
||
streamID := w.nextHTTPStreamID()
|
||
|
||
// Channel buffer size for response frames to avoid blocking readLoop.
|
||
// A typical HTTP response has: 1 StreamOpen + N StreamData + 1 StreamClose frames.
|
||
// With 4KB chunks, even large responses stay within this buffer.
|
||
const responseChannelBuffer = 16
|
||
|
||
// Create a pending request to receive responses
|
||
pending := &pendingRequest{
|
||
streamID: streamID,
|
||
respCh: make(chan *protocol.Envelope, responseChannelBuffer),
|
||
doneCh: make(chan struct{}),
|
||
}
|
||
w.pending[streamID] = pending
|
||
w.mu.Unlock()
|
||
|
||
// 서버 → 클라이언트 방향 요청 바디 전송에 대한 송신 측 ARQ 상태를 준비합니다. (ko)
|
||
// Prepare ARQ sender state for the request body sent from server to client. (en)
|
||
sender := newStreamSender()
|
||
w.registerStreamSender(streamID, sender)
|
||
|
||
// Ensure cleanup on exit
|
||
defer func() {
|
||
w.mu.Lock()
|
||
delete(w.pending, streamID)
|
||
w.mu.Unlock()
|
||
close(pending.doneCh)
|
||
w.unregisterStreamSender(streamID)
|
||
}()
|
||
|
||
log := logger.With(logging.Fields{
|
||
"component": "http_to_dtls",
|
||
"request_id": string(streamID),
|
||
"method": req.Method,
|
||
"url": req.URL.String(),
|
||
})
|
||
|
||
log.Info("forwarding http request over dtls (stream mode)", logging.Fields{
|
||
"host": req.Host,
|
||
"scheme": req.URL.Scheme,
|
||
})
|
||
|
||
// 요청 헤더를 복사하고 pseudo-header 로 HTTP 메타데이터를 추가합니다. (ko)
|
||
// Copy request headers and attach HTTP metadata as pseudo-headers. (en)
|
||
hdr := make(map[string][]string, len(req.Header)+3)
|
||
for k, vs := range req.Header {
|
||
hdr[k] = append([]string(nil), vs...)
|
||
}
|
||
hdr[protocol.HeaderKeyMethod] = []string{req.Method}
|
||
if req.URL != nil {
|
||
hdr[protocol.HeaderKeyURL] = []string{req.URL.String()}
|
||
}
|
||
host := req.Host
|
||
if host == "" && req.URL != nil {
|
||
host = req.URL.Host
|
||
}
|
||
if host != "" {
|
||
hdr[protocol.HeaderKeyHost] = []string{host}
|
||
}
|
||
|
||
// StreamOpen 전송: 어떤 서비스로 라우팅해야 하는지와 초기 헤더를 전달합니다. (ko)
|
||
// Send StreamOpen to indicate which service to route to and initial headers. (en)
|
||
openEnv := &protocol.Envelope{
|
||
Type: protocol.MessageTypeStreamOpen,
|
||
StreamOpen: &protocol.StreamOpen{
|
||
ID: streamID,
|
||
Service: serviceName,
|
||
TargetAddr: "",
|
||
Header: hdr,
|
||
},
|
||
}
|
||
if err := w.codec.Encode(w.sess, openEnv); err != nil {
|
||
log.Error("failed to encode stream_open envelope", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
return nil, err
|
||
}
|
||
|
||
// 요청 바디를 4KiB(StreamChunkSize) 단위로 잘라 StreamData 프레임으로 전송합니다. (ko)
|
||
// Chunk the request body into 4KiB (StreamChunkSize) StreamData frames. (en)
|
||
var seq uint64
|
||
if req.Body != nil {
|
||
buf := make([]byte, protocol.StreamChunkSize)
|
||
for {
|
||
n, err := req.Body.Read(buf)
|
||
if n > 0 {
|
||
dataCopy := append([]byte(nil), buf[:n]...)
|
||
// 송신 측 ARQ: Seq 별 payload 를 기록해 두었다가, 클라이언트의 StreamAck 를 기반으로 재전송합니다. (ko)
|
||
// Sender-side ARQ: record payload per Seq so it can be retransmitted based on StreamAck from the client. (en)
|
||
sender.register(seq, dataCopy)
|
||
|
||
dataEnv := &protocol.Envelope{
|
||
Type: protocol.MessageTypeStreamData,
|
||
StreamData: &protocol.StreamData{
|
||
ID: streamID,
|
||
Seq: seq,
|
||
Data: dataCopy,
|
||
},
|
||
}
|
||
if err2 := w.codec.Encode(w.sess, dataEnv); err2 != nil {
|
||
log.Error("failed to encode stream_data envelope", logging.Fields{
|
||
"error": err2.Error(),
|
||
})
|
||
return nil, err2
|
||
}
|
||
seq++
|
||
}
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read http request body for streaming: %w", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 바디 종료를 알리는 StreamClose 를 전송합니다. (ko)
|
||
// Send StreamClose to mark the end of the request body. (en)
|
||
closeReqEnv := &protocol.Envelope{
|
||
Type: protocol.MessageTypeStreamClose,
|
||
StreamClose: &protocol.StreamClose{
|
||
ID: streamID,
|
||
Error: "",
|
||
},
|
||
}
|
||
if err := w.codec.Encode(w.sess, closeReqEnv); err != nil {
|
||
log.Error("failed to encode request stream_close envelope", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
return nil, err
|
||
}
|
||
|
||
// 클라이언트로부터 역방향 스트림 응답을 수신합니다. (ko)
|
||
// Receive reverse stream response (StreamOpen + StreamData* + StreamClose) via the readLoop. (en)
|
||
var (
|
||
resp protocol.Response
|
||
bodyBuf bytes.Buffer
|
||
gotOpen bool
|
||
statusCode = http.StatusOK
|
||
|
||
// 응답 바디(클라이언트 → 서버)에 대한 수신 측 ARQ 상태입니다. (ko)
|
||
// ARQ receiver state for the response body (client → server). (en)
|
||
expectedSeq uint64
|
||
received = make(map[uint64][]byte)
|
||
lost = make(map[uint64]struct{})
|
||
)
|
||
const maxLostReport = 32
|
||
|
||
resp.RequestID = string(streamID)
|
||
resp.Header = make(map[string][]string)
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
log.Error("context cancelled while waiting for response", logging.Fields{
|
||
"error": ctx.Err().Error(),
|
||
})
|
||
return nil, ctx.Err()
|
||
|
||
case <-w.readerDone:
|
||
log.Error("dtls session closed while waiting for response", nil)
|
||
return nil, fmt.Errorf("dtls session closed")
|
||
|
||
case env, ok := <-pending.respCh:
|
||
if !ok {
|
||
// Channel closed, session is dead
|
||
log.Error("response channel closed unexpectedly", nil)
|
||
return nil, fmt.Errorf("response channel closed")
|
||
}
|
||
|
||
switch env.Type {
|
||
case protocol.MessageTypeStreamOpen:
|
||
so := env.StreamOpen
|
||
if so == nil {
|
||
return nil, fmt.Errorf("stream_open response payload is nil")
|
||
}
|
||
// 상태 코드 및 헤더 복원 (pseudo-header 제거). (ko)
|
||
// Restore status code and headers (strip pseudo-headers). (en)
|
||
statusStr := firstHeaderValue(so.Header, protocol.HeaderKeyStatus, strconv.Itoa(http.StatusOK))
|
||
if sc, err := strconv.Atoi(statusStr); err == nil && sc > 0 {
|
||
statusCode = sc
|
||
}
|
||
for k, vs := range so.Header {
|
||
if k == protocol.HeaderKeyMethod ||
|
||
k == protocol.HeaderKeyURL ||
|
||
k == protocol.HeaderKeyHost ||
|
||
k == protocol.HeaderKeyStatus {
|
||
continue
|
||
}
|
||
resp.Header[k] = append([]string(nil), vs...)
|
||
}
|
||
gotOpen = true
|
||
|
||
case protocol.MessageTypeStreamData:
|
||
sd := env.StreamData
|
||
if sd == nil {
|
||
return nil, fmt.Errorf("stream_data response payload is nil")
|
||
}
|
||
|
||
// 수신 측 ARQ: Seq 에 따라 분기하고, 연속 구간을 bodyBuf 에 순서대로 기록합니다. (ko)
|
||
// Receiver-side ARQ: handle Seq and append contiguous data to bodyBuf in order. (en)
|
||
switch {
|
||
case sd.Seq == expectedSeq:
|
||
if len(sd.Data) > 0 {
|
||
if _, err := bodyBuf.Write(sd.Data); err != nil {
|
||
return nil, fmt.Errorf("buffer stream_data response: %w", err)
|
||
}
|
||
}
|
||
expectedSeq++
|
||
for {
|
||
data, ok := received[expectedSeq]
|
||
if !ok {
|
||
break
|
||
}
|
||
if len(data) > 0 {
|
||
if _, err := bodyBuf.Write(data); err != nil {
|
||
return nil, fmt.Errorf("buffer reordered stream_data response: %w", err)
|
||
}
|
||
}
|
||
delete(received, expectedSeq)
|
||
delete(lost, expectedSeq)
|
||
expectedSeq++
|
||
}
|
||
|
||
// AckSeq 이전 구간의 lost 항목 정리
|
||
for seq := range lost {
|
||
if seq < expectedSeq {
|
||
delete(lost, seq)
|
||
}
|
||
}
|
||
|
||
case sd.Seq > expectedSeq:
|
||
// 앞선 일부 Seq 들이 누락된 상태: 현재 프레임을 버퍼링하고 missing seq 들을 lost 에 추가. (ko)
|
||
// Missing earlier Seq: buffer this frame and mark missing seqs as lost. (en)
|
||
if len(sd.Data) > 0 {
|
||
bufCopy := make([]byte, len(sd.Data))
|
||
copy(bufCopy, sd.Data)
|
||
received[sd.Seq] = bufCopy
|
||
}
|
||
for seq := expectedSeq; seq < sd.Seq && len(lost) < maxLostReport; seq++ {
|
||
if _, ok := lost[seq]; !ok {
|
||
lost[seq] = struct{}{}
|
||
}
|
||
}
|
||
|
||
default:
|
||
// sd.Seq < expectedSeq 인 경우: 이미 처리했거나 Ack 로 커버된 프레임 → 무시. (ko)
|
||
// sd.Seq < expectedSeq: already processed/acked frame → ignore. (en)
|
||
}
|
||
|
||
// 수신 측 StreamAck 전송:
|
||
// - AckSeq: 0부터 시작해 연속으로 수신 완료한 마지막 시퀀스 (expectedSeq-1)
|
||
// - LostSeqs: 현재 윈도우 내에서 누락된 시퀀스 중 상한 개수(maxLostReport)까지만 포함 (ko)
|
||
// Send receiver-side StreamAck:
|
||
// - AckSeq: last contiguously received sequence starting from 0 (expectedSeq-1)
|
||
// - LostSeqs: up to maxLostReport missing sequences in the current window. (en)
|
||
var ackSeq uint64
|
||
if expectedSeq == 0 {
|
||
ackSeq = 0
|
||
} else {
|
||
ackSeq = expectedSeq - 1
|
||
}
|
||
|
||
lostSeqs := make([]uint64, 0, len(lost))
|
||
for seq := range lost {
|
||
if seq >= expectedSeq {
|
||
lostSeqs = append(lostSeqs, seq)
|
||
}
|
||
}
|
||
if len(lostSeqs) > 0 {
|
||
sort.Slice(lostSeqs, func(i, j int) bool { return lostSeqs[i] < lostSeqs[j] })
|
||
if len(lostSeqs) > maxLostReport {
|
||
lostSeqs = lostSeqs[:maxLostReport]
|
||
}
|
||
}
|
||
|
||
ackEnv := protocol.Envelope{
|
||
Type: protocol.MessageTypeStreamAck,
|
||
StreamAck: &protocol.StreamAck{
|
||
ID: streamID,
|
||
AckSeq: ackSeq,
|
||
LostSeqs: lostSeqs,
|
||
},
|
||
}
|
||
if err := w.codec.Encode(w.sess, &ackEnv); err != nil {
|
||
return nil, fmt.Errorf("send stream ack: %w", err)
|
||
}
|
||
|
||
case protocol.MessageTypeStreamClose:
|
||
sc := env.StreamClose
|
||
if sc == nil {
|
||
return nil, fmt.Errorf("stream_close response payload is nil")
|
||
}
|
||
// 스트림 종료: 지금까지 수신한 헤더/바디로 protocol.Response 를 완성합니다. (ko)
|
||
// Stream finished: complete protocol.Response using collected headers/body. (en)
|
||
resp.Status = statusCode
|
||
resp.Body = bodyBuf.Bytes()
|
||
resp.Error = sc.Error
|
||
|
||
log.Info("received stream http response over dtls", logging.Fields{
|
||
"status": resp.Status,
|
||
"error": resp.Error,
|
||
})
|
||
if !gotOpen {
|
||
return nil, fmt.Errorf("received stream_close without prior stream_open for stream %q", streamID)
|
||
}
|
||
return &resp, nil
|
||
|
||
default:
|
||
return nil, fmt.Errorf("unexpected envelope type %q in stream response", env.Type)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// nextHTTPStreamID 는 DTLS 세션 내 HTTP 요청에 사용할 고유 StreamID 를 생성합니다. (ko)
|
||
// nextHTTPStreamID generates a unique StreamID for HTTP requests on this DTLS session. (en)
|
||
func (w *dtlsSessionWrapper) nextHTTPStreamID() protocol.StreamID {
|
||
id := w.nextStreamID
|
||
w.nextStreamID++
|
||
return protocol.StreamID(fmt.Sprintf("http-%d", id))
|
||
}
|
||
|
||
// firstHeaderValue 는 map[string][]string 형태의 헤더에서 첫 번째 값을 반환하고,
|
||
// 값이 없으면 기본값을 반환합니다. (ko)
|
||
// firstHeaderValue returns the first value for a header key in map[string][]string,
|
||
// or the provided default if the key is missing or empty. (en)
|
||
func firstHeaderValue(hdr map[string][]string, key, def string) string {
|
||
if hdr == nil {
|
||
return def
|
||
}
|
||
if vs, ok := hdr[key]; ok && len(vs) > 0 {
|
||
return vs[0]
|
||
}
|
||
return def
|
||
}
|
||
|
||
var (
|
||
sessionsMu sync.RWMutex
|
||
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)
|
||
}
|
||
|
||
// 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": {},
|
||
}
|
||
|
||
// 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/<status>.html (또는 ./errors/<status>.html) (ko)
|
||
// 2. go:embed 로 내장된 templates/<status>.html (ko)
|
||
//
|
||
// Template loading priority: (en)
|
||
// 1. HOP_ERROR_PAGES_DIR/<status>.html (or ./errors/<status>.html) (en)
|
||
// 2. go:embed'ed templates/<status>.html (en)
|
||
func writeErrorPage(w http.ResponseWriter, r *http.Request, status int) {
|
||
// 공통 보안/식별 헤더를 best-effort 로 설정합니다. (ko)
|
||
// Configure common security and identity headers (best-effort). (en)
|
||
if r != nil {
|
||
setSecurityAndIdentityHeaders(w, r)
|
||
}
|
||
|
||
// Delegates actual HTML rendering to internal/errorpages. (en)
|
||
// 실제 HTML 렌더링은 internal/errorpages 패키지에 위임합니다. (ko)
|
||
errorpages.Render(w, r, status)
|
||
}
|
||
|
||
// 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 {
|
||
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 페이지로 응답하여 노출을 최소화합니다. (ko)
|
||
// For metrics/admin endpoints, respond with a 404 page when host mismatches to reduce exposure. (en)
|
||
writeErrorPage(w, r, http.StatusNotFound)
|
||
return
|
||
}
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func registerSessionForDomain(domain string, sess dtls.Session, logger logging.Logger) {
|
||
d := strings.ToLower(strings.TrimSpace(domain))
|
||
if d == "" {
|
||
return
|
||
}
|
||
w := &dtlsSessionWrapper{
|
||
sess: sess,
|
||
bufferedReader: bufio.NewReaderSize(sess, protocol.GetDTLSReadBufferSize()),
|
||
codec: protocol.DefaultCodec,
|
||
logger: logger.With(logging.Fields{"component": "dtls_session_wrapper", "domain": d}),
|
||
pending: make(map[protocol.StreamID]*pendingRequest),
|
||
readerDone: make(chan struct{}),
|
||
streamSenders: make(map[protocol.StreamID]*streamSender),
|
||
}
|
||
|
||
// Start background reader goroutine to demultiplex incoming responses
|
||
go w.readLoop()
|
||
|
||
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, proxyTimeout time.Duration) 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"))
|
||
|
||
// HOP_SERVER_DOMAIN 은 관리/제어용 도메인으로 사용되며, 프록시 대상 도메인이 아닙니다.
|
||
// 이 도메인으로 직접 접근하는 일반 요청은 400 Bad Request 로 응답해야 합니다. (ko)
|
||
// HOP_SERVER_DOMAIN is used as the control/admin domain and is not a proxied
|
||
// origin. Plain HTTP requests to this host should return 400 Bad Request. (en)
|
||
allowedDomain := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_SERVER_DOMAIN")))
|
||
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// NOTE: /__hopgate_assets__/ 경로는 DTLS/백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko)
|
||
// 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만,
|
||
// 혹시라도 라우팅/구성이 꼬여서 이쪽으로 들어오는 경우를 방지하기 위해
|
||
// /__hopgate_assets__/ 요청은 여기서도 강제로 정적 핸들러로 처리합니다. (ko)
|
||
//
|
||
// The /__hopgate_assets__/ path must always serve static assets independently
|
||
// of DTLS/backend state. This handler is intended for the generic proxy path (/),
|
||
// but as a safety net, we short-circuit asset requests here as well. (en)
|
||
if strings.HasPrefix(r.URL.Path, "/__hopgate_assets__/") {
|
||
if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil {
|
||
staticFS := http.FileServer(http.FS(sub))
|
||
http.StripPrefix("/__hopgate_assets__/", staticFS).ServeHTTP(w, r)
|
||
return
|
||
}
|
||
// embed FS 가 초기화되지 않은 비정상 상황에서는 500 에러 페이지로 폴백합니다. (ko)
|
||
// If embedded FS is not available for some reason, fall back to a 500 error page. (en)
|
||
writeErrorPage(w, r, http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
start := time.Now()
|
||
method := r.Method
|
||
|
||
// 상태 코드 캡처를 위한 래퍼
|
||
sr := &statusRecorder{
|
||
ResponseWriter: w,
|
||
status: http.StatusOK,
|
||
}
|
||
// 보안/식별 헤더를 공통으로 설정합니다. (ko)
|
||
// Configure common security and identity headers. (en)
|
||
setSecurityAndIdentityHeaders(sr, r)
|
||
|
||
log := logger.With(logging.Fields{
|
||
"component": "http_entry",
|
||
"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 == "" {
|
||
observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc()
|
||
writeErrorPage(sr, r, 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(),
|
||
})
|
||
observability.ProxyErrorsTotal.WithLabelValues("acme_http01_error").Inc()
|
||
writeErrorPage(sr, r, http.StatusNotFound)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
// ACME challenge 응답은 일반적으로 text/plain.
|
||
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
|
||
}
|
||
|
||
// 2. 일반 HTTP 요청은 DTLS 를 통해 클라이언트로 포워딩
|
||
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선.
|
||
serviceName := "web"
|
||
|
||
// Host 헤더에서 포트를 제거하고 소문자로 정규화합니다.
|
||
host := r.Host
|
||
if i := strings.Index(host, ":"); i != -1 {
|
||
host = host[:i]
|
||
}
|
||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||
|
||
// HOP_SERVER_DOMAIN 로 들어온 일반 요청은 프록시 대상이 아니므로 400 으로 응답합니다. (ko)
|
||
// Plain requests to HOP_SERVER_DOMAIN are not proxied and should return 400. (en)
|
||
if allowedDomain != "" && hostLower == allowedDomain {
|
||
log.Warn("request to control domain is not proxied", logging.Fields{
|
||
"host": r.Host,
|
||
"allowed_domain": allowedDomain,
|
||
"path": r.URL.Path,
|
||
})
|
||
observability.ProxyErrorsTotal.WithLabelValues("invalid_control_domain_request").Inc()
|
||
writeErrorPage(sr, r, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
sessWrapper := getSessionForHost(hostLower)
|
||
if sessWrapper == nil {
|
||
log.Warn("no dtls session for host", logging.Fields{
|
||
"host": r.Host,
|
||
})
|
||
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc()
|
||
// 등록되지 않았거나 활성 세션이 없는 도메인으로의 요청은 404 로 응답합니다. (ko)
|
||
// Requests for hosts without an active DTLS session return 404. (en)
|
||
writeErrorPage(sr, r, http.StatusNotFound)
|
||
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()
|
||
|
||
// 서버 측에서 DTLS → 클라이언트 → 로컬 서비스까지의 전체 왕복 시간을 제한하기 위해
|
||
// 요청 컨텍스트에 타임아웃을 적용합니다. 기본값은 15초이며,
|
||
// HOP_SERVER_PROXY_TIMEOUT_SECONDS 로 재정의할 수 있습니다. (ko)
|
||
// Apply an overall timeout (default 15s, configurable via
|
||
// HOP_SERVER_PROXY_TIMEOUT_SECONDS) to the DTLS forward path so that
|
||
// excessively slow backends surface as gateway timeouts. (en)
|
||
ctx := r.Context()
|
||
if proxyTimeout > 0 {
|
||
var cancel context.CancelFunc
|
||
ctx, cancel = context.WithTimeout(ctx, proxyTimeout)
|
||
defer cancel()
|
||
}
|
||
|
||
type forwardResult struct {
|
||
resp *protocol.Response
|
||
err error
|
||
}
|
||
resultCh := make(chan forwardResult, 1)
|
||
|
||
go func() {
|
||
select {
|
||
case <-ctx.Done():
|
||
// Context cancelled, do not proceed.
|
||
return
|
||
default:
|
||
resp, err := sessWrapper.ForwardHTTP(ctx, logger, r, serviceName)
|
||
resultCh <- forwardResult{resp: resp, err: err}
|
||
}
|
||
}()
|
||
|
||
var protoResp *protocol.Response
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
log.Error("forward over dtls timed out", logging.Fields{
|
||
"timeout_seconds": int64(proxyTimeout.Seconds()),
|
||
"error": ctx.Err().Error(),
|
||
})
|
||
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_timeout").Inc()
|
||
writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
|
||
return
|
||
|
||
case res := <-resultCh:
|
||
if res.err != nil {
|
||
log.Error("forward over dtls failed", logging.Fields{
|
||
"error": res.err.Error(),
|
||
})
|
||
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_failed").Inc()
|
||
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
|
||
return
|
||
}
|
||
protoResp = res.resp
|
||
}
|
||
|
||
// 응답 헤더/바디 복원
|
||
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)
|
||
}
|
||
}
|
||
if protoResp.Status == 0 {
|
||
protoResp.Status = http.StatusOK
|
||
}
|
||
sr.WriteHeader(protoResp.Status)
|
||
if len(protoResp.Body) > 0 {
|
||
if _, err := sr.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")
|
||
|
||
// 1. 서버 설정 로드 (.env + 환경변수)
|
||
// internal/config 패키지가 .env 를 먼저 읽고, 이미 설정된 OS 환경변수를 우선시합니다.
|
||
cfg, err := config.LoadServerConfigFromEnv()
|
||
if err != nil {
|
||
logger.Error("failed to load server config from env", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
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",
|
||
"version": version,
|
||
"http_listen": cfg.HTTPListen,
|
||
"https_listen": cfg.HTTPSListen,
|
||
"dtls_listen": cfg.DTLSListen,
|
||
"domain": cfg.Domain,
|
||
"debug": cfg.Debug,
|
||
})
|
||
|
||
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{
|
||
"error": err.Error(),
|
||
})
|
||
os.Exit(1)
|
||
}
|
||
defer dbClient.Close()
|
||
|
||
logger.Info("postgres connected and schema ready", logging.Fields{
|
||
"component": "store",
|
||
})
|
||
|
||
// 3.1 Admin Plane: DomainService + Admin HTTP handler 구성
|
||
adminService := admin.NewDomainService(logger, dbClient)
|
||
|
||
// Admin API 키는 환경변수에서 읽어옵니다.
|
||
// - HOP_ADMIN_API_KEY 가 비어 있으면, 모든 Admin API 요청이 거부됩니다.
|
||
adminAPIKey := strings.TrimSpace(os.Getenv("HOP_ADMIN_API_KEY"))
|
||
if adminAPIKey == "" {
|
||
logger.Warn("HOP_ADMIN_API_KEY is not set; admin API will reject all requests", logging.Fields{
|
||
"component": "admin_api",
|
||
})
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
// HOP_ACME_STANDALONE_ONLY=true 인 경우, ACME 인증서만 발급/갱신하고 프로세스를 종료합니다.
|
||
// 이 모드는 HTTP/DTLS 서버를 띄우지 않고 lego(ACME client)만 단독으로 실행할 때 사용합니다.
|
||
standaloneOnly := func() bool {
|
||
v := strings.ToLower(strings.TrimSpace(os.Getenv("HOP_ACME_STANDALONE_ONLY")))
|
||
switch v {
|
||
case "1", "true", "yes", "y", "on":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}()
|
||
if standaloneOnly {
|
||
logger.Info("running ACME standalone-only mode", logging.Fields{
|
||
"domains": domains,
|
||
"use_staging": cfg.Debug,
|
||
})
|
||
|
||
// ACME(lego) 매니저 초기화: 도메인 DNS 확인 + 인증서 확보/갱신 + 캐시 저장
|
||
// 이 호출이 끝나면 해당 도메인에 대한 인증서가 HOP_ACME_CACHE_DIR 에 준비되어 있어야 합니다.
|
||
acmeCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||
defer cancel()
|
||
|
||
if _, err := acme.NewLegoManagerFromEnv(acmeCtx, logger, domains); err != nil {
|
||
logger.Error("acme standalone mode failed", logging.Fields{
|
||
"error": err.Error(),
|
||
"domains": domains,
|
||
})
|
||
os.Exit(1)
|
||
}
|
||
|
||
logger.Info("acme standalone mode completed successfully, exiting process", logging.Fields{
|
||
"domains": domains,
|
||
})
|
||
return
|
||
}
|
||
|
||
// 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{
|
||
"error": err.Error(),
|
||
})
|
||
os.Exit(1)
|
||
}
|
||
logger.Warn("using self-signed localhost certificate for DTLS (debug mode)", logging.Fields{
|
||
"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: dtlsTLSConfig,
|
||
})
|
||
if err != nil {
|
||
logger.Error("failed to start dtls server", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
os.Exit(1)
|
||
}
|
||
defer dtlsServer.Close()
|
||
|
||
logger.Info("dtls server listening", logging.Fields{
|
||
"addr": cfg.DTLSListen,
|
||
})
|
||
|
||
// 5. HTTP / HTTPS 서버 시작
|
||
// 프록시 타임아웃은 HOP_SERVER_PROXY_TIMEOUT_SECONDS(초 단위) 로 설정할 수 있으며,
|
||
// 기본값은 15초입니다. (ko)
|
||
// The proxy timeout can be configured via HOP_SERVER_PROXY_TIMEOUT_SECONDS
|
||
// (in seconds); the default is 15 seconds. (en)
|
||
proxyTimeout := 15 * time.Second
|
||
if v := strings.TrimSpace(os.Getenv("HOP_SERVER_PROXY_TIMEOUT_SECONDS")); v != "" {
|
||
if secs, err := strconv.Atoi(v); err != nil {
|
||
logger.Warn("invalid HOP_SERVER_PROXY_TIMEOUT_SECONDS format, using default", logging.Fields{
|
||
"value": v,
|
||
"error": err,
|
||
})
|
||
} else if secs <= 0 {
|
||
logger.Warn("HOP_SERVER_PROXY_TIMEOUT_SECONDS must be positive, using default", logging.Fields{
|
||
"value": v,
|
||
})
|
||
}
|
||
}
|
||
logger.Info("http proxy timeout configured", logging.Fields{
|
||
"timeout_seconds": int64(proxyTimeout.Seconds()),
|
||
})
|
||
|
||
httpHandler := newHTTPHandler(logger, proxyTimeout)
|
||
|
||
// Prometheus /metrics 엔드포인트 및 메인 핸들러를 위한 mux 구성
|
||
httpMux := http.NewServeMux()
|
||
allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain))
|
||
|
||
// __hopgate_assets__ prefix:
|
||
// HopGate 서버가 직접 Tailwind CSS, 로고 등 정적 에셋을 서빙하기 위한 경로입니다. (ko)
|
||
// This prefix is used for static assets (Tailwind CSS, logos, etc.) served directly by HopGate. (en)
|
||
//
|
||
// 우선순위: (ko)
|
||
// 1) HOP_ERROR_ASSETS_DIR 가 설정되어 있으면 해당 디렉터리 (디스크 기반)
|
||
// 2) 없으면 internal/errorpages/assets 에 내장된 go:embed 에셋 사용
|
||
//
|
||
// Priority: (en)
|
||
// 1) HOP_ERROR_ASSETS_DIR if set (disk-based)
|
||
// 2) Otherwise, use go:embed'ed assets under internal/errorpages/assets
|
||
assetDir := strings.TrimSpace(os.Getenv("HOP_ERROR_ASSETS_DIR"))
|
||
if assetDir != "" {
|
||
fs := http.FileServer(http.Dir(assetDir))
|
||
httpMux.Handle("/__hopgate_assets/",
|
||
hostDomainHandler(allowedDomain, logger,
|
||
http.StripPrefix("/__hopgate_assets/", fs),
|
||
),
|
||
)
|
||
} else {
|
||
// Embedded assets under internal/errorpages/assets.
|
||
if sub, err := stdfs.Sub(errorpages.AssetsFS, "assets"); err == nil {
|
||
staticFS := http.FileServer(http.FS(sub))
|
||
httpMux.Handle("/__hopgate_assets/",
|
||
hostDomainHandler(allowedDomain, logger,
|
||
http.StripPrefix("/__hopgate_assets/", staticFS),
|
||
),
|
||
)
|
||
} else {
|
||
logger.Warn("failed to init embedded assets filesystem", logging.Fields{
|
||
"component": "error_assets",
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// /metrics 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 접근 가능하도록 제한합니다.
|
||
httpMux.Handle("/metrics", hostDomainHandler(allowedDomain, logger, promhttp.Handler()))
|
||
|
||
// Admin Plane HTTP mux: /api/v1/admin/* 경로를 처리합니다.
|
||
// - Authorization: Bearer {HOP_ADMIN_API_KEY} 헤더를 사용해 인증합니다.
|
||
adminHandler := admin.NewHandler(logger, adminAPIKey, adminService)
|
||
adminMux := http.NewServeMux()
|
||
adminHandler.RegisterRoutes(adminMux)
|
||
httpMux.Handle("/api/v1/admin/", hostDomainHandler(allowedDomain, logger, adminMux))
|
||
|
||
// 기본 HTTP → DTLS Proxy 엔트리 포인트
|
||
httpMux.Handle("/", httpHandler)
|
||
|
||
// HTTP: 평문 포트
|
||
httpSrv := &http.Server{
|
||
Addr: cfg.HTTPListen,
|
||
Handler: httpMux,
|
||
}
|
||
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: httpMux,
|
||
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. 도메인 검증기 준비 (ent + PostgreSQL 기반 실제 구현)
|
||
// Admin Plane 에서 관리하는 Domain 테이블을 사용해 (domain, client_api_key) 조합을 검증합니다.
|
||
domainValidator := admin.NewEntDomainValidator(logger, dbClient)
|
||
|
||
// DTLS 핸드셰이크 단계에서는 클라이언트가 제시한 도메인의 DNS(A/AAAA)가
|
||
// HOP_ACME_EXPECT_IPS 에 설정된 IP들 중 하나 이상을 가리키는지 추가로 검증합니다. (ko)
|
||
// During DTLS handshake, additionally verify that the presented domain resolves
|
||
// (via A/AAAA) to at least one IP configured in HOP_ACME_EXPECT_IPS. (en)
|
||
// EXPECT_IPS 가 비어 있으면 DNS 기반 검증은 생략하고 DB 검증만 수행합니다. (ko)
|
||
// If EXPECT_IPS is empty, only DB-based validation is performed. (en)
|
||
expectedHandshakeIPs := parseExpectedIPsFromEnv(logger, "HOP_ACME_EXPECT_IPS")
|
||
var validator dtls.DomainValidator = &domainGateValidator{
|
||
expectedIPs: expectedHandshakeIPs,
|
||
inner: domainValidator,
|
||
logger: logger,
|
||
}
|
||
|
||
// 7. DTLS Accept 루프 + Handshake
|
||
for {
|
||
sess, err := dtlsServer.Accept()
|
||
if err != nil {
|
||
logger.Error("dtls accept failed", logging.Fields{
|
||
"error": err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 각 세션별로 goroutine 에서 핸드셰이크 및 후속 처리를 수행합니다.
|
||
go func(s dtls.Session) {
|
||
// NOTE: 세션은 HTTP↔DTLS 터널링에 계속 사용해야 하므로 이곳에서 Close 하지 않습니다.
|
||
// 세션 종료/타임아웃 관리는 별도의 세션 매니저(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(),
|
||
"error": err.Error(),
|
||
})
|
||
// 핸드셰이크 실패 시 세션을 명시적으로 종료하여 invalid SNI 등 오류에서
|
||
// 연결이 열린 채로 남지 않도록 합니다.
|
||
_ = s.Close()
|
||
return
|
||
}
|
||
|
||
// Handshake 성공 메트릭 기록
|
||
observability.DTLSHandshakesTotal.WithLabelValues("success").Inc()
|
||
|
||
// Handshake 성공: 서버 측은 어떤 도메인이 연결되었는지 알 수 있습니다.
|
||
logger.Info("dtls handshake completed", logging.Fields{
|
||
"session_id": s.ID(),
|
||
"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 요청을 이 세션을 통해 해당 클라이언트로 라우팅
|
||
// - 세션 생명주기/타임아웃 관리 등
|
||
}(sess)
|
||
}
|
||
}
|