mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-07 20:35:44 +09:00
[feat](server): add 504 Gateway Timeout support and enhance buffer handling
- Introduced `StatusGatewayTimeout` (504) for server-side timeouts between HopGate and backend. - Implemented 504 error page with multilingual support. - Enhanced `bufio.Reader` usage in JSON decoding to prevent "dtls: buffer too small" errors for large payloads. - Applied request handling improvements for control domain and timeout scenarios.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -217,7 +218,15 @@ func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Log
|
|||||||
|
|
||||||
// 클라이언트로부터 HTTP 응답 Envelope 를 수신합니다.
|
// 클라이언트로부터 HTTP 응답 Envelope 를 수신합니다.
|
||||||
var respEnv protocol.Envelope
|
var respEnv protocol.Envelope
|
||||||
dec := json.NewDecoder(w.sess)
|
|
||||||
|
// NOTE: pion/dtls 는 복호화된 애플리케이션 데이터를 호출자가 제공한 버퍼에 채웁니다.
|
||||||
|
// 기본 JSON 디코더 버퍼만 사용하면 큰 HTTP 응답/Envelope 에서 "dtls: buffer too small"
|
||||||
|
// 오류가 발생할 수 있으므로, 충분히 큰 bufio.Reader(64KiB)를 사용합니다. (ko)
|
||||||
|
// NOTE: pion/dtls decrypts application data into the buffer provided by the caller.
|
||||||
|
// Using only the default JSON decoder buffer can cause "dtls: buffer too small"
|
||||||
|
// errors for large HTTP responses/envelopes, so we wrap the session with a
|
||||||
|
// reasonably large bufio.Reader (64KiB). (en)
|
||||||
|
dec := json.NewDecoder(bufio.NewReaderSize(w.sess, 64*1024))
|
||||||
if err := dec.Decode(&respEnv); err != nil {
|
if err := dec.Decode(&respEnv); err != nil {
|
||||||
log.Error("failed to decode http envelope", logging.Fields{
|
log.Error("failed to decode http envelope", logging.Fields{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -370,10 +379,16 @@ func getSessionForHost(host string) *dtlsSessionWrapper {
|
|||||||
return sessionsByDomain[h]
|
return sessionsByDomain[h]
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPHandler(logger logging.Logger) http.Handler {
|
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.
|
// 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"))
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// NOTE: /__hopgate_assets__/ 경로는 DTLS/백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko)
|
// NOTE: /__hopgate_assets__/ 경로는 DTLS/백엔드와 무관하게 항상 정적 에셋만 서빙해야 합니다. (ko)
|
||||||
// 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만,
|
// 이 핸들러(newHTTPHandler)는 일반 프록시 경로(/)에만 사용되어야 하지만,
|
||||||
@@ -469,13 +484,35 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
|||||||
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선.
|
// 간단한 서비스 이름 결정: 우선 "web" 고정, 추후 Router 도입 시 개선.
|
||||||
serviceName := "web"
|
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(r.Host)
|
sessWrapper := getSessionForHost(r.Host)
|
||||||
if sessWrapper == nil {
|
if sessWrapper == nil {
|
||||||
log.Warn("no dtls session for host", logging.Fields{
|
log.Warn("no dtls session for host", logging.Fields{
|
||||||
"host": r.Host,
|
"host": r.Host,
|
||||||
})
|
})
|
||||||
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc()
|
observability.ProxyErrorsTotal.WithLabelValues("no_dtls_session").Inc()
|
||||||
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
|
// 등록되지 않았거나 활성 세션이 없는 도메인으로의 요청은 404 로 응답합니다. (ko)
|
||||||
|
// Requests for hosts without an active DTLS session return 404. (en)
|
||||||
|
writeErrorPage(sr, r, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,15 +542,52 @@ func newHTTPHandler(logger logging.Logger) http.Handler {
|
|||||||
// r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기
|
// r.Body 는 ForwardHTTP 내에서 읽고 닫지 않으므로 여기서 닫기
|
||||||
defer r.Body.Close()
|
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()
|
ctx := r.Context()
|
||||||
protoResp, err := sessWrapper.ForwardHTTP(ctx, logger, r, serviceName)
|
if proxyTimeout > 0 {
|
||||||
if err != nil {
|
var cancel context.CancelFunc
|
||||||
log.Error("forward over dtls failed", logging.Fields{
|
ctx, cancel = context.WithTimeout(ctx, proxyTimeout)
|
||||||
"error": err.Error(),
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
type forwardResult struct {
|
||||||
|
resp *protocol.Response
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultCh := make(chan forwardResult, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
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_failed").Inc()
|
observability.ProxyErrorsTotal.WithLabelValues("dtls_forward_timeout").Inc()
|
||||||
writeErrorPage(sr, r, errorpages.StatusTLSHandshakeFailed)
|
writeErrorPage(sr, r, errorpages.StatusGatewayTimeout)
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답 헤더/바디 복원
|
// 응답 헤더/바디 복원
|
||||||
@@ -730,7 +804,26 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 5. HTTP / HTTPS 서버 시작
|
// 5. HTTP / HTTPS 서버 시작
|
||||||
httpHandler := newHTTPHandler(logger)
|
// 프록시 타임아웃은 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 || secs <= 0 {
|
||||||
|
logger.Warn("invalid HOP_SERVER_PROXY_TIMEOUT_SECONDS, using default", logging.Fields{
|
||||||
|
"value": v,
|
||||||
|
"error": err,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
proxyTimeout = time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Info("http proxy timeout configured", logging.Fields{
|
||||||
|
"timeout_seconds": int64(proxyTimeout.Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
httpHandler := newHTTPHandler(logger, proxyTimeout)
|
||||||
|
|
||||||
// Prometheus /metrics 엔드포인트 및 메인 핸들러를 위한 mux 구성
|
// Prometheus /metrics 엔드포인트 및 메인 핸들러를 위한 mux 구성
|
||||||
httpMux := http.NewServeMux()
|
httpMux := http.NewServeMux()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dtls
|
package dtls
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -61,7 +62,16 @@ func PerformServerHandshake(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req handshakeRequest
|
var req handshakeRequest
|
||||||
if err := json.NewDecoder(sess).Decode(&req); err != nil {
|
// NOTE: pion/dtls 는 application plaintext 를 Caller's buffer 에 복호화하므로,
|
||||||
|
// JSON 디코더가 사용하는 버퍼 크기가 너무 작으면 "dtls: buffer too small" 이 발생할 수 있습니다.
|
||||||
|
// 이를 피하기 위해 충분히 큰 bufio.Reader(예: 64KiB)를 사용합니다. (ko)
|
||||||
|
// pion/dtls decrypts application data into the buffer provided by the caller.
|
||||||
|
// To avoid "dtls: buffer too small" errors when JSON payloads are larger than
|
||||||
|
// the default decoder buffer, we wrap the session in a bufio.Reader with a
|
||||||
|
// sufficiently large size (e.g. 64KiB). (en)
|
||||||
|
dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024))
|
||||||
|
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
log.Error("failed to read handshake request", logging.Fields{
|
log.Error("failed to read handshake request", logging.Fields{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
@@ -151,7 +161,11 @@ func PerformClientHandshake(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp handshakeResponse
|
var resp handshakeResponse
|
||||||
if err := json.NewDecoder(sess).Decode(&resp); err != nil {
|
// 클라이언트 측에서도 동일하게 큰 버퍼를 사용해 "buffer too small" 오류를 방지합니다. (ko)
|
||||||
|
// Use the same larger buffer on the client side as well. (en)
|
||||||
|
dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024))
|
||||||
|
|
||||||
|
if err := dec.Decode(&resp); err != nil {
|
||||||
log.Error("failed to read handshake response", logging.Fields{
|
log.Error("failed to read handshake response", logging.Fields{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
|
// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
|
||||||
const StatusTLSHandshakeFailed = 525
|
const StatusTLSHandshakeFailed = 525
|
||||||
|
|
||||||
|
// StatusGatewayTimeout is an HTTP-style status code representing
|
||||||
|
// a gateway timeout between HopGate and the backend (similar to 504).
|
||||||
|
// HopGate 와 백엔드 간 요청이 너무 오래 걸려 타임아웃된 경우를 나타내는 상태 코드입니다. (예: 504)
|
||||||
|
const StatusGatewayTimeout = http.StatusGatewayTimeout
|
||||||
|
|
||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
var embeddedTemplatesFS embed.FS
|
var embeddedTemplatesFS embed.FS
|
||||||
|
|
||||||
|
|||||||
32
internal/errorpages/templates/504.html
Normal file
32
internal/errorpages/templates/504.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>504 Gateway Timeout - HopGate</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="/__hopgate_assets__/errors.css">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-950 text-slate-50 flex items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-xl text-center">
|
||||||
|
<div class="items-center justify-center gap-3 mb-8 flex flex-col">
|
||||||
|
<img src="/__hopgate_assets__/hop-gate.png" alt="HopGate" class="h-8 w-[240px] opacity-90" />
|
||||||
|
<h2 class="text-md font-medium tracking-[0.25em] uppercase text-slate-400">HopGate</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-flex items-baseline gap-4 mb-4">
|
||||||
|
<span class="text-6xl md:text-7xl font-extrabold tracking-[0.25em] text-amber-200">504</span>
|
||||||
|
<span class="text-lg md:text-xl font-semibold text-slate-100">Gateway Timeout</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm md:text-base text-slate-300 leading-relaxed">
|
||||||
|
The request to the backend service took too long and was timed out by HopGate.<br>
|
||||||
|
백엔드 서비스로의 요청이 너무 오래 걸려 HopGate 에서 타임아웃 처리되었습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 text-xs md:text-sm text-slate-500">
|
||||||
|
This may happen when the origin is overloaded or responding very slowly.<br>
|
||||||
|
원본 서버가 과부하 상태이거나 응답이 매우 느린 경우에 발생할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -61,7 +62,14 @@ func (p *ClientProxy) StartLoop(ctx context.Context, sess dtls.Session) error {
|
|||||||
}
|
}
|
||||||
log := p.Logger
|
log := p.Logger
|
||||||
|
|
||||||
dec := json.NewDecoder(sess)
|
// NOTE: pion/dtls 는 복호화된 애플리케이션 데이터를 호출자가 제공한 버퍼에 채워 넣습니다.
|
||||||
|
// 기본 JSON 디코더 버퍼(수백 바이트 수준)만 사용하면 큰 HTTP 바디/Envelope 에서
|
||||||
|
// "dtls: buffer too small" 오류가 날 수 있으므로, 여기서는 여유 있는 버퍼(64KiB)를 사용합니다. (ko)
|
||||||
|
// NOTE: pion/dtls decrypts application data into the buffer provided by the caller.
|
||||||
|
// Using only the default JSON decoder buffer (a few hundred bytes) can trigger
|
||||||
|
// "dtls: buffer too small" for large HTTP bodies/envelopes, so we wrap the
|
||||||
|
// session with a reasonably large bufio.Reader (64KiB). (en)
|
||||||
|
dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024))
|
||||||
enc := json.NewEncoder(sess)
|
enc := json.NewEncoder(sess)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
Reference in New Issue
Block a user