Merge pull request #61 from bnema/security/proxy-hardening-top10
security(proxy) harden reverse proxy (top 10 audit items)
11 files · +497 -101
M internal/adapters/in/cli/remote/client.go +15 -5
| @@ -92,10 +92,17 @@ func (c *Client) applyTLSConfig() { | |||
| 92 | 92 | fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL) | |
| 93 | 93 | ||
| 94 | 94 | var transport *http.Transport | |
| 95 | - | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | |
| 96 | - | transport = existing.Clone() | |
| 97 | - | } else { | |
| 95 | + | switch t := c.httpClient.Transport.(type) { | |
| 96 | + | case *http.Transport: | |
| 97 | + | transport = t.Clone() | |
| 98 | + | case nil: | |
| 98 | 99 | transport = http.DefaultTransport.(*http.Transport).Clone() | |
| 100 | + | default: | |
| 101 | + | // Cannot apply InsecureSkipVerify to a non-*http.Transport. | |
| 102 | + | // Log a warning and leave the transport intact rather than | |
| 103 | + | // silently replacing it (which would drop caller-provided behavior). | |
| 104 | + | fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t) | |
| 105 | + | return | |
| 99 | 106 | } | |
| 100 | 107 | ||
| 101 | 108 | if transport.TLSClientConfig == nil { | |
| @@ -741,8 +748,11 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||
| 741 | 748 | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 742 | 749 | } | |
| 743 | 750 | ||
| 744 | - | // Use a client without timeout for streaming | |
| 745 | - | streamClient := &http.Client{} | |
| 751 | + | // Use the same transport as the main client (honoring TLS config and | |
| 752 | + | // custom transports) but without a timeout so streaming doesn't get cut off. | |
| 753 | + | streamClient := &http.Client{ | |
| 754 | + | Transport: c.httpClient.Transport, | |
| 755 | + | } | |
| 746 | 756 | resp, err := streamClient.Do(req) | |
| 747 | 757 | if err != nil { | |
| 748 | 758 | return nil, fmt.Errorf("failed to connect: %w", err) | |
| @@ -92,10 +92,17 @@ func (c *Client) applyTLSConfig() { | |||||
| 92 | fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL) | 92 | fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL) | ||
| 93 | 93 | ||||
| 94 | var transport *http.Transport | 94 | var transport *http.Transport | ||
| 95 | - | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | 95 | + | switch t := c.httpClient.Transport.(type) { |
| 96 | - | transport = existing.Clone() | 96 | + | case *http.Transport: |
| 97 | - | } else { | 97 | + | transport = t.Clone() |
| 98 | + | case nil: | |||
| 98 | transport = http.DefaultTransport.(*http.Transport).Clone() | 99 | transport = http.DefaultTransport.(*http.Transport).Clone() | ||
| 100 | + | default: | |||
| 101 | + | // Cannot apply InsecureSkipVerify to a non-*http.Transport. | |||
| 102 | + | // Log a warning and leave the transport intact rather than | |||
| 103 | + | // silently replacing it (which would drop caller-provided behavior). | |||
| 104 | + | fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t) | |||
| 105 | + | return | |||
| 99 | } | 106 | } | ||
| 100 | 107 | ||||
| 101 | if transport.TLSClientConfig == nil { | 108 | if transport.TLSClientConfig == nil { | ||
| @@ -741,8 +748,11 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||||
| 741 | req.Header.Set("Authorization", "Bearer "+c.token) | 748 | req.Header.Set("Authorization", "Bearer "+c.token) | ||
| 742 | } | 749 | } | ||
| 743 | 750 | ||||
| 744 | - | // Use a client without timeout for streaming | 751 | + | // Use the same transport as the main client (honoring TLS config and |
| 745 | - | streamClient := &http.Client{} | 752 | + | // custom transports) but without a timeout so streaming doesn't get cut off. |
| 753 | + | streamClient := &http.Client{ | |||
| 754 | + | Transport: c.httpClient.Transport, | |||
| 755 | + | } | |||
| 746 | resp, err := streamClient.Do(req) | 756 | resp, err := streamClient.Do(req) | ||
| 747 | if err != nil { | 757 | if err != nil { | ||
| 748 | return nil, fmt.Errorf("failed to connect: %w", err) | 758 | return nil, fmt.Errorf("failed to connect: %w", err) | ||
M internal/adapters/in/http/middleware/logging.go +25 -0
| @@ -2,9 +2,13 @@ | |||
| 2 | 2 | package middleware | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "crypto/rand" | |
| 6 | + | "encoding/hex" | |
| 5 | 7 | "encoding/json" | |
| 8 | + | "fmt" | |
| 6 | 9 | "net" | |
| 7 | 10 | "net/http" | |
| 11 | + | "sync/atomic" | |
| 8 | 12 | "time" | |
| 9 | 13 | ||
| 10 | 14 | "github.com/bnema/zerowrap" | |
| @@ -75,6 +79,13 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||
| 75 | 79 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 76 | 80 | start := time.Now() | |
| 77 | 81 | ||
| 82 | + | // Generate or reuse X-Request-ID for request tracing | |
| 83 | + | requestID := r.Header.Get("X-Request-ID") | |
| 84 | + | if requestID == "" { | |
| 85 | + | requestID = generateRequestID() | |
| 86 | + | } | |
| 87 | + | w.Header().Set("X-Request-ID", requestID) | |
| 88 | + | ||
| 78 | 89 | // Wrap the response writer to capture status and bytes | |
| 79 | 90 | rw := NewResponseWriter(w) | |
| 80 | 91 | ||
| @@ -96,6 +107,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||
| 96 | 107 | log.Info(). | |
| 97 | 108 | Str(zerowrap.FieldLayer, "adapter"). | |
| 98 | 109 | Str(zerowrap.FieldAdapter, "http"). | |
| 110 | + | Str("request_id", requestID). | |
| 99 | 111 | Str(zerowrap.FieldMethod, r.Method). | |
| 100 | 112 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 101 | 113 | Str("query", r.URL.RawQuery). | |
| @@ -112,6 +124,19 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||
| 112 | 124 | } | |
| 113 | 125 | } | |
| 114 | 126 | ||
| 127 | + | // fallbackCounter ensures uniqueness when crypto/rand is unavailable. | |
| 128 | + | var fallbackCounter atomic.Uint64 | |
| 129 | + | ||
| 130 | + | // generateRequestID creates a random 16-byte hex-encoded request ID. | |
| 131 | + | func generateRequestID() string { | |
| 132 | + | b := make([]byte, 16) | |
| 133 | + | if _, err := rand.Read(b); err != nil { | |
| 134 | + | // Fallback to timestamp + monotonic counter if RNG is unavailable | |
| 135 | + | return fmt.Sprintf("%x-%x", time.Now().UnixNano(), fallbackCounter.Add(1)) | |
| 136 | + | } | |
| 137 | + | return hex.EncodeToString(b) | |
| 138 | + | } | |
| 139 | + | ||
| 115 | 140 | // PanicRecovery middleware recovers from panics and logs them. | |
| 116 | 141 | func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 117 | 142 | return func(next http.Handler) http.Handler { | |
| @@ -2,9 +2,13 @@ | |||||
| 2 | package middleware | 2 | package middleware | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "crypto/rand" | |||
| 6 | + | "encoding/hex" | |||
| 5 | "encoding/json" | 7 | "encoding/json" | ||
| 8 | + | "fmt" | |||
| 6 | "net" | 9 | "net" | ||
| 7 | "net/http" | 10 | "net/http" | ||
| 11 | + | "sync/atomic" | |||
| 8 | "time" | 12 | "time" | ||
| 9 | 13 | ||||
| 10 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| @@ -75,6 +79,13 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 75 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 79 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 76 | start := time.Now() | 80 | start := time.Now() | ||
| 77 | 81 | ||||
| 82 | + | // Generate or reuse X-Request-ID for request tracing | |||
| 83 | + | requestID := r.Header.Get("X-Request-ID") | |||
| 84 | + | if requestID == "" { | |||
| 85 | + | requestID = generateRequestID() | |||
| 86 | + | } | |||
| 87 | + | w.Header().Set("X-Request-ID", requestID) | |||
| 88 | + | ||||
| 78 | // Wrap the response writer to capture status and bytes | 89 | // Wrap the response writer to capture status and bytes | ||
| 79 | rw := NewResponseWriter(w) | 90 | rw := NewResponseWriter(w) | ||
| 80 | 91 | ||||
| @@ -96,6 +107,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 96 | log.Info(). | 107 | log.Info(). | ||
| 97 | Str(zerowrap.FieldLayer, "adapter"). | 108 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 98 | Str(zerowrap.FieldAdapter, "http"). | 109 | Str(zerowrap.FieldAdapter, "http"). | ||
| 110 | + | Str("request_id", requestID). | |||
| 99 | Str(zerowrap.FieldMethod, r.Method). | 111 | Str(zerowrap.FieldMethod, r.Method). | ||
| 100 | Str(zerowrap.FieldPath, r.URL.Path). | 112 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 101 | Str("query", r.URL.RawQuery). | 113 | Str("query", r.URL.RawQuery). | ||
| @@ -112,6 +124,19 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 112 | } | 124 | } | ||
| 113 | } | 125 | } | ||
| 114 | 126 | ||||
| 127 | + | // fallbackCounter ensures uniqueness when crypto/rand is unavailable. | |||
| 128 | + | var fallbackCounter atomic.Uint64 | |||
| 129 | + | ||||
| 130 | + | // generateRequestID creates a random 16-byte hex-encoded request ID. | |||
| 131 | + | func generateRequestID() string { | |||
| 132 | + | b := make([]byte, 16) | |||
| 133 | + | if _, err := rand.Read(b); err != nil { | |||
| 134 | + | // Fallback to timestamp + monotonic counter if RNG is unavailable | |||
| 135 | + | return fmt.Sprintf("%x-%x", time.Now().UnixNano(), fallbackCounter.Add(1)) | |||
| 136 | + | } | |||
| 137 | + | return hex.EncodeToString(b) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 115 | // PanicRecovery middleware recovers from panics and logs them. | 140 | // PanicRecovery middleware recovers from panics and logs them. | ||
| 116 | func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler { | 141 | func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| 117 | return func(next http.Handler) http.Handler { | 142 | return func(next http.Handler) http.Handler { | ||
M internal/adapters/in/http/middleware/security.go +13 -0
| @@ -23,6 +23,19 @@ func SecurityHeaders(next http.Handler) http.Handler { | |||
| 23 | 23 | // Restrict browser features | |
| 24 | 24 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 25 | 25 | ||
| 26 | + | // HSTS: Enforce HTTPS-only for browsers. Only set when the request | |
| 27 | + | // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto | |
| 28 | + | // here because it is client-spoofable unless the request came from a | |
| 29 | + | // trusted proxy, and we have no trusted-proxy list at this layer. | |
| 30 | + | if r.TLS != nil { | |
| 31 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | |
| 35 | + | // apps, so a blanket "default-src 'none'" would break all proxied sites. | |
| 36 | + | // Instead, CSP is set directly on proxy-generated error responses in the | |
| 37 | + | // proxy error handlers (service.go). | |
| 38 | + | ||
| 26 | 39 | next.ServeHTTP(w, r) | |
| 27 | 40 | }) | |
| 28 | 41 | } | |
| @@ -23,6 +23,19 @@ func SecurityHeaders(next http.Handler) http.Handler { | |||||
| 23 | // Restrict browser features | 23 | // Restrict browser features | ||
| 24 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | 24 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | ||
| 25 | 25 | ||||
| 26 | + | // HSTS: Enforce HTTPS-only for browsers. Only set when the request | |||
| 27 | + | // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto | |||
| 28 | + | // here because it is client-spoofable unless the request came from a | |||
| 29 | + | // trusted proxy, and we have no trusted-proxy list at this layer. | |||
| 30 | + | if r.TLS != nil { | |||
| 31 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | |||
| 35 | + | // apps, so a blanket "default-src 'none'" would break all proxied sites. | |||
| 36 | + | // Instead, CSP is set directly on proxy-generated error responses in the | |||
| 37 | + | // proxy error handlers (service.go). | |||
| 38 | + | ||||
| 26 | next.ServeHTTP(w, r) | 39 | next.ServeHTTP(w, r) | ||
| 27 | }) | 40 | }) | ||
| 28 | } | 41 | } | ||
M internal/adapters/in/http/middleware/security_test.go +38 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/tls" | |
| 4 | 5 | "net/http" | |
| 5 | 6 | "net/http/httptest" | |
| 6 | 7 | "testing" | |
| @@ -27,6 +28,43 @@ func TestSecurityHeaders(t *testing.T) { | |||
| 27 | 28 | assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) | |
| 28 | 29 | assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy")) | |
| 29 | 30 | assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy")) | |
| 31 | + | ||
| 32 | + | // CSP should NOT be set globally (would break proxied sites) | |
| 33 | + | assert.Empty(t, rec.Header().Get("Content-Security-Policy")) | |
| 34 | + | ||
| 35 | + | // HSTS should NOT be set on plain HTTP | |
| 36 | + | assert.Empty(t, rec.Header().Get("Strict-Transport-Security")) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |
| 40 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 41 | + | w.WriteHeader(http.StatusOK) | |
| 42 | + | }) | |
| 43 | + | ||
| 44 | + | wrapped := SecurityHeaders(handler) | |
| 45 | + | ||
| 46 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 47 | + | req.TLS = &tls.ConnectionState{} // Simulate TLS connection | |
| 48 | + | rec := httptest.NewRecorder() | |
| 49 | + | wrapped.ServeHTTP(rec, req) | |
| 50 | + | ||
| 51 | + | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | |
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | + | w.WriteHeader(http.StatusOK) | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | wrapped := SecurityHeaders(handler) | |
| 60 | + | ||
| 61 | + | // X-Forwarded-Proto is client-spoofable and should NOT trigger HSTS | |
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 63 | + | req.Header.Set("X-Forwarded-Proto", "https") | |
| 64 | + | rec := httptest.NewRecorder() | |
| 65 | + | wrapped.ServeHTTP(rec, req) | |
| 66 | + | ||
| 67 | + | assert.Empty(t, rec.Header().Get("Strict-Transport-Security"), "HSTS should not be set based on spoofable X-Forwarded-Proto") | |
| 30 | 68 | } | |
| 31 | 69 | ||
| 32 | 70 | func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package middleware | 1 | package middleware | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "crypto/tls" | |||
| 4 | "net/http" | 5 | "net/http" | ||
| 5 | "net/http/httptest" | 6 | "net/http/httptest" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -27,6 +28,43 @@ func TestSecurityHeaders(t *testing.T) { | |||||
| 27 | assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) | 28 | assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) | ||
| 28 | assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy")) | 29 | assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy")) | ||
| 29 | assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy")) | 30 | assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy")) | ||
| 31 | + | ||||
| 32 | + | // CSP should NOT be set globally (would break proxied sites) | |||
| 33 | + | assert.Empty(t, rec.Header().Get("Content-Security-Policy")) | |||
| 34 | + | ||||
| 35 | + | // HSTS should NOT be set on plain HTTP | |||
| 36 | + | assert.Empty(t, rec.Header().Get("Strict-Transport-Security")) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |||
| 40 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 41 | + | w.WriteHeader(http.StatusOK) | |||
| 42 | + | }) | |||
| 43 | + | ||||
| 44 | + | wrapped := SecurityHeaders(handler) | |||
| 45 | + | ||||
| 46 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 47 | + | req.TLS = &tls.ConnectionState{} // Simulate TLS connection | |||
| 48 | + | rec := httptest.NewRecorder() | |||
| 49 | + | wrapped.ServeHTTP(rec, req) | |||
| 50 | + | ||||
| 51 | + | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | |||
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | + | w.WriteHeader(http.StatusOK) | |||
| 57 | + | }) | |||
| 58 | + | ||||
| 59 | + | wrapped := SecurityHeaders(handler) | |||
| 60 | + | ||||
| 61 | + | // X-Forwarded-Proto is client-spoofable and should NOT trigger HSTS | |||
| 62 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 63 | + | req.Header.Set("X-Forwarded-Proto", "https") | |||
| 64 | + | rec := httptest.NewRecorder() | |||
| 65 | + | wrapped.ServeHTTP(rec, req) | |||
| 66 | + | ||||
| 67 | + | assert.Empty(t, rec.Header().Get("Strict-Transport-Security"), "HSTS should not be set based on spoofable X-Forwarded-Proto") | |||
| 30 | } | 68 | } | ||
| 31 | 69 | ||||
| 32 | func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { | 70 | func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { | ||
M internal/adapters/in/http/proxy/handler.go +3 -1
| @@ -71,6 +71,8 @@ func (h *Handler) Start(ctx context.Context, handler http.Handler) error { | |||
| 71 | 71 | Str(zerowrap.FieldLayer, "adapter"). | |
| 72 | 72 | Str(zerowrap.FieldAdapter, "http"). | |
| 73 | 73 | Msg("proxy server shutting down") | |
| 74 | - | return server.Shutdown(context.Background()) | |
| 74 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 75 | + | defer shutdownCancel() | |
| 76 | + | return server.Shutdown(shutdownCtx) | |
| 75 | 77 | } | |
| 76 | 78 | } | |
| @@ -71,6 +71,8 @@ func (h *Handler) Start(ctx context.Context, handler http.Handler) error { | |||||
| 71 | Str(zerowrap.FieldLayer, "adapter"). | 71 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 72 | Str(zerowrap.FieldAdapter, "http"). | 72 | Str(zerowrap.FieldAdapter, "http"). | ||
| 73 | Msg("proxy server shutting down") | 73 | Msg("proxy server shutting down") | ||
| 74 | - | return server.Shutdown(context.Background()) | 74 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 75 | + | defer shutdownCancel() | |||
| 76 | + | return server.Shutdown(shutdownCtx) | |||
| 75 | } | 77 | } | ||
| 76 | } | 78 | } | ||
M internal/app/run.go +122 -62
| @@ -70,17 +70,19 @@ import ( | |||
| 70 | 70 | // Config holds the application configuration. | |
| 71 | 71 | type Config struct { | |
| 72 | 72 | Server struct { | |
| 73 | - | Port int `mapstructure:"port"` | |
| 74 | - | RegistryPort int `mapstructure:"registry_port"` | |
| 75 | - | GordonDomain string `mapstructure:"gordon_domain"` | |
| 76 | - | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 77 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | |
| 78 | - | TLSPort int `mapstructure:"tls_port"` | |
| 79 | - | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 80 | - | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 81 | - | DataDir string `mapstructure:"data_dir"` | |
| 82 | - | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 83 | - | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| 73 | + | Port int `mapstructure:"port"` | |
| 74 | + | RegistryPort int `mapstructure:"registry_port"` | |
| 75 | + | GordonDomain string `mapstructure:"gordon_domain"` | |
| 76 | + | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 77 | + | TLSEnabled bool `mapstructure:"tls_enabled"` | |
| 78 | + | TLSPort int `mapstructure:"tls_port"` | |
| 79 | + | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 80 | + | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 81 | + | DataDir string `mapstructure:"data_dir"` | |
| 82 | + | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 83 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| 84 | + | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | |
| 85 | + | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | |
| 84 | 86 | } `mapstructure:"server"` | |
| 85 | 87 | ||
| 86 | 88 | Logging struct { | |
| @@ -431,33 +433,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 431 | 433 | } | |
| 432 | 434 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 433 | 435 | ||
| 434 | - | // Parse max_proxy_body_size config (default: 512MB) | |
| 435 | - | maxProxyBodySize := int64(512 << 20) // 512MB default | |
| 436 | - | if cfg.Server.MaxProxyBodySize != "" { | |
| 437 | - | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize) | |
| 438 | - | if err != nil { | |
| 439 | - | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize}) | |
| 440 | - | } | |
| 441 | - | maxProxyBodySize = parsedSize | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | // Parse max_blob_chunk_size config (default: 95MB) | |
| 445 | - | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |
| 446 | - | if cfg.Server.MaxBlobChunkSize != "" { | |
| 447 | - | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |
| 448 | - | if err != nil { | |
| 449 | - | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |
| 450 | - | } | |
| 451 | - | maxBlobChunkSize = parsedSize | |
| 436 | + | proxyCfg, err := buildProxyConfig(cfg, log) | |
| 437 | + | if err != nil { | |
| 438 | + | return nil, err | |
| 452 | 439 | } | |
| 453 | - | ||
| 454 | - | svc.maxBlobChunkSize = maxBlobChunkSize | |
| 455 | - | ||
| 456 | - | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | |
| 457 | - | RegistryDomain: cfg.Server.RegistryDomain, | |
| 458 | - | RegistryPort: cfg.Server.RegistryPort, | |
| 459 | - | MaxBodySize: maxProxyBodySize, | |
| 460 | - | }) | |
| 440 | + | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 441 | + | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | |
| 461 | 442 | ||
| 462 | 443 | // Create token handler for registry token endpoint | |
| 463 | 444 | if svc.authSvc != nil { | |
| @@ -983,6 +964,59 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||
| 983 | 964 | } | |
| 984 | 965 | } | |
| 985 | 966 | ||
| 967 | + | // proxyConfigResult holds parsed proxy and blob chunk size config. | |
| 968 | + | type proxyConfigResult struct { | |
| 969 | + | proxyConfig proxy.Config | |
| 970 | + | maxBlobChunkSize int64 | |
| 971 | + | } | |
| 972 | + | ||
| 973 | + | // buildProxyConfig parses size-related config fields and builds the proxy config. | |
| 974 | + | func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) { | |
| 975 | + | maxProxyBodySize := int64(512 << 20) // 512MB default | |
| 976 | + | if cfg.Server.MaxProxyBodySize != "" { | |
| 977 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize) | |
| 978 | + | if err != nil { | |
| 979 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize}) | |
| 980 | + | } | |
| 981 | + | maxProxyBodySize = parsedSize | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |
| 985 | + | if cfg.Server.MaxBlobChunkSize != "" { | |
| 986 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |
| 987 | + | if err != nil { | |
| 988 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |
| 989 | + | } | |
| 990 | + | maxBlobChunkSize = parsedSize | |
| 991 | + | } | |
| 992 | + | ||
| 993 | + | maxProxyResponseSize := int64(1 << 30) // 1GB default | |
| 994 | + | if cfg.Server.MaxProxyResponseSize != "" { | |
| 995 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize) | |
| 996 | + | if err != nil { | |
| 997 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize}) | |
| 998 | + | } | |
| 999 | + | maxProxyResponseSize = parsedSize | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | maxConcurrentConns := cfg.Server.MaxConcurrentConns | |
| 1003 | + | if maxConcurrentConns < 0 { | |
| 1004 | + | maxConcurrentConns = 10000 // default when explicitly set to -1 | |
| 1005 | + | } | |
| 1006 | + | // 0 means no limit (as documented in proxy.Config) | |
| 1007 | + | ||
| 1008 | + | return &proxyConfigResult{ | |
| 1009 | + | proxyConfig: proxy.Config{ | |
| 1010 | + | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1011 | + | RegistryPort: cfg.Server.RegistryPort, | |
| 1012 | + | MaxBodySize: maxProxyBodySize, | |
| 1013 | + | MaxResponseSize: maxProxyResponseSize, | |
| 1014 | + | MaxConcurrentConns: maxConcurrentConns, | |
| 1015 | + | }, | |
| 1016 | + | maxBlobChunkSize: maxBlobChunkSize, | |
| 1017 | + | }, nil | |
| 1018 | + | } | |
| 1019 | + | ||
| 986 | 1020 | // createContainerService creates the container service with configuration. | |
| 987 | 1021 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 988 | 1022 | containerConfig := container.Config{ | |
| @@ -1083,11 +1117,21 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||
| 1083 | 1117 | return | |
| 1084 | 1118 | } | |
| 1085 | 1119 | ||
| 1086 | - | // Update proxy config from viper (reads directly from viper, not memory) | |
| 1087 | - | svc.proxySvc.UpdateConfig(proxy.Config{ | |
| 1088 | - | RegistryDomain: v.GetString("server.registry_domain"), | |
| 1089 | - | RegistryPort: v.GetInt("server.registry_port"), | |
| 1090 | - | }) | |
| 1120 | + | // Re-unmarshal config and rebuild proxy config to pick up all changes | |
| 1121 | + | var reloadCfg Config | |
| 1122 | + | if err := v.Unmarshal(&reloadCfg); err != nil { | |
| 1123 | + | log.Error().Err(err).Msg("failed to unmarshal config on reload") | |
| 1124 | + | return | |
| 1125 | + | } | |
| 1126 | + | if reloadCfg.Server.GordonDomain != "" { | |
| 1127 | + | reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain | |
| 1128 | + | } | |
| 1129 | + | reloadedProxy, err := buildProxyConfig(reloadCfg, log) | |
| 1130 | + | if err != nil { | |
| 1131 | + | log.Error().Err(err).Msg("failed to parse proxy config on reload") | |
| 1132 | + | return | |
| 1133 | + | } | |
| 1134 | + | svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | |
| 1091 | 1135 | ||
| 1092 | 1136 | // Clear proxy target cache to pick up external route changes | |
| 1093 | 1137 | if err := svc.proxySvc.RefreshTargets(ctx); err != nil { | |
| @@ -1309,39 +1353,54 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 1309 | 1353 | } | |
| 1310 | 1354 | ||
| 1311 | 1355 | shutdown: | |
| 1356 | + | gracefulShutdown(registrySrv, proxySrv, containerSvc, log) | |
| 1357 | + | return nil | |
| 1358 | + | } | |
| 1359 | + | ||
| 1360 | + | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 1361 | + | // the container service and cleans up runtime files. | |
| 1362 | + | func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { | |
| 1312 | 1363 | log.Info().Msg("shutting down Gordon...") | |
| 1313 | 1364 | ||
| 1314 | - | if err := containerSvc.Shutdown(ctx); err != nil { | |
| 1365 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 1366 | + | defer shutdownCancel() | |
| 1367 | + | ||
| 1368 | + | if err := registrySrv.Shutdown(shutdownCtx); err != nil { | |
| 1369 | + | log.Warn().Err(err).Msg("registry server shutdown error") | |
| 1370 | + | } | |
| 1371 | + | if err := proxySrv.Shutdown(shutdownCtx); err != nil { | |
| 1372 | + | log.Warn().Err(err).Msg("proxy server shutdown error") | |
| 1373 | + | } | |
| 1374 | + | ||
| 1375 | + | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | |
| 1315 | 1376 | log.Warn().Err(err).Msg("error during container shutdown") | |
| 1316 | 1377 | } | |
| 1317 | 1378 | ||
| 1318 | - | // Clean up internal credentials file | |
| 1319 | 1379 | cleanupInternalCredentials() | |
| 1320 | - | ||
| 1321 | 1380 | log.Info().Msg("Gordon stopped") | |
| 1322 | - | return nil | |
| 1323 | 1381 | } | |
| 1324 | 1382 | ||
| 1325 | - | // startServer starts an HTTP server, returning a channel that closes once the | |
| 1326 | - | // listening socket is bound. This lets callers wait for the port to be ready | |
| 1327 | - | // before taking actions that depend on it (e.g. auto-start pulling from the | |
| 1328 | - | // local registry). | |
| 1329 | - | func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} { | |
| 1383 | + | // startServer starts an HTTP server, returning the server instance and a channel | |
| 1384 | + | // that closes once the listening socket is bound. This lets callers wait for the | |
| 1385 | + | // port to be ready before taking actions that depend on it (e.g. auto-start | |
| 1386 | + | // pulling from the local registry). The returned *http.Server can be used for | |
| 1387 | + | // graceful shutdown. | |
| 1388 | + | func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 1330 | 1389 | ready := make(chan struct{}) | |
| 1331 | 1390 | ||
| 1391 | + | server := &http.Server{ | |
| 1392 | + | Addr: addr, | |
| 1393 | + | Handler: handler, | |
| 1394 | + | ReadHeaderTimeout: 10 * time.Second, | |
| 1395 | + | ReadTimeout: 5 * time.Minute, | |
| 1396 | + | WriteTimeout: 5 * time.Minute, | |
| 1397 | + | IdleTimeout: 120 * time.Second, | |
| 1398 | + | MaxHeaderBytes: 1 << 20, | |
| 1399 | + | } | |
| 1400 | + | ||
| 1332 | 1401 | go func() { | |
| 1333 | 1402 | log.Info().Str("address", addr).Msgf("%s server starting", name) | |
| 1334 | 1403 | ||
| 1335 | - | server := &http.Server{ | |
| 1336 | - | Addr: addr, | |
| 1337 | - | Handler: handler, | |
| 1338 | - | ReadHeaderTimeout: 10 * time.Second, | |
| 1339 | - | ReadTimeout: 5 * time.Minute, | |
| 1340 | - | WriteTimeout: 5 * time.Minute, | |
| 1341 | - | IdleTimeout: 120 * time.Second, | |
| 1342 | - | MaxHeaderBytes: 1 << 20, | |
| 1343 | - | } | |
| 1344 | - | ||
| 1345 | 1404 | ln, err := net.Listen("tcp", addr) | |
| 1346 | 1405 | if err != nil { | |
| 1347 | 1406 | errChan <- fmt.Errorf("%s server error: %w", name, err) | |
| @@ -1354,7 +1413,7 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||
| 1354 | 1413 | } | |
| 1355 | 1414 | }() | |
| 1356 | 1415 | ||
| 1357 | - | return ready | |
| 1416 | + | return server, ready | |
| 1358 | 1417 | } | |
| 1359 | 1418 | ||
| 1360 | 1419 | // startTLSServer starts an HTTPS server with the provided certificate and key. | |
| @@ -1680,6 +1739,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1680 | 1739 | v.SetDefault("volumes.prefix", "gordon") | |
| 1681 | 1740 | v.SetDefault("volumes.preserve", true) | |
| 1682 | 1741 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1742 | + | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1683 | 1743 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 1684 | 1744 | ||
| 1685 | 1745 | ConfigureViper(v, configPath) | |
| @@ -70,17 +70,19 @@ import ( | |||||
| 70 | // Config holds the application configuration. | 70 | // Config holds the application configuration. | ||
| 71 | type Config struct { | 71 | type Config struct { | ||
| 72 | Server struct { | 72 | Server struct { | ||
| 73 | - | Port int `mapstructure:"port"` | 73 | + | Port int `mapstructure:"port"` |
| 74 | - | RegistryPort int `mapstructure:"registry_port"` | 74 | + | RegistryPort int `mapstructure:"registry_port"` |
| 75 | - | GordonDomain string `mapstructure:"gordon_domain"` | 75 | + | GordonDomain string `mapstructure:"gordon_domain"` |
| 76 | - | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | 76 | + | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain |
| 77 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | 77 | + | TLSEnabled bool `mapstructure:"tls_enabled"` |
| 78 | - | TLSPort int `mapstructure:"tls_port"` | 78 | + | TLSPort int `mapstructure:"tls_port"` |
| 79 | - | TLSCertFile string `mapstructure:"tls_cert_file"` | 79 | + | TLSCertFile string `mapstructure:"tls_cert_file"` |
| 80 | - | TLSKeyFile string `mapstructure:"tls_key_file"` | 80 | + | TLSKeyFile string `mapstructure:"tls_key_file"` |
| 81 | - | DataDir string `mapstructure:"data_dir"` | 81 | + | DataDir string `mapstructure:"data_dir"` |
| 82 | - | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 82 | + | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" |
| 83 | - | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | 83 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" |
| 84 | + | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | |||
| 85 | + | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | |||
| 84 | } `mapstructure:"server"` | 86 | } `mapstructure:"server"` | ||
| 85 | 87 | ||||
| 86 | Logging struct { | 88 | Logging struct { | ||
| @@ -431,33 +433,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 431 | } | 433 | } | ||
| 432 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 434 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 433 | 435 | ||||
| 434 | - | // Parse max_proxy_body_size config (default: 512MB) | 436 | + | proxyCfg, err := buildProxyConfig(cfg, log) |
| 435 | - | maxProxyBodySize := int64(512 << 20) // 512MB default | 437 | + | if err != nil { |
| 436 | - | if cfg.Server.MaxProxyBodySize != "" { | 438 | + | return nil, err |
| 437 | - | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize) | |||
| 438 | - | if err != nil { | |||
| 439 | - | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize}) | |||
| 440 | - | } | |||
| 441 | - | maxProxyBodySize = parsedSize | |||
| 442 | - | } | |||
| 443 | - | ||||
| 444 | - | // Parse max_blob_chunk_size config (default: 95MB) | |||
| 445 | - | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |||
| 446 | - | if cfg.Server.MaxBlobChunkSize != "" { | |||
| 447 | - | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |||
| 448 | - | if err != nil { | |||
| 449 | - | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |||
| 450 | - | } | |||
| 451 | - | maxBlobChunkSize = parsedSize | |||
| 452 | } | 439 | } | ||
| 453 | - | 440 | + | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 454 | - | svc.maxBlobChunkSize = maxBlobChunkSize | 441 | + | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) |
| 455 | - | ||||
| 456 | - | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | |||
| 457 | - | RegistryDomain: cfg.Server.RegistryDomain, | |||
| 458 | - | RegistryPort: cfg.Server.RegistryPort, | |||
| 459 | - | MaxBodySize: maxProxyBodySize, | |||
| 460 | - | }) | |||
| 461 | 442 | ||||
| 462 | // Create token handler for registry token endpoint | 443 | // Create token handler for registry token endpoint | ||
| 463 | if svc.authSvc != nil { | 444 | if svc.authSvc != nil { | ||
| @@ -983,6 +964,59 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||||
| 983 | } | 964 | } | ||
| 984 | } | 965 | } | ||
| 985 | 966 | ||||
| 967 | + | // proxyConfigResult holds parsed proxy and blob chunk size config. | |||
| 968 | + | type proxyConfigResult struct { | |||
| 969 | + | proxyConfig proxy.Config | |||
| 970 | + | maxBlobChunkSize int64 | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | // buildProxyConfig parses size-related config fields and builds the proxy config. | |||
| 974 | + | func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) { | |||
| 975 | + | maxProxyBodySize := int64(512 << 20) // 512MB default | |||
| 976 | + | if cfg.Server.MaxProxyBodySize != "" { | |||
| 977 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize) | |||
| 978 | + | if err != nil { | |||
| 979 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize}) | |||
| 980 | + | } | |||
| 981 | + | maxProxyBodySize = parsedSize | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |||
| 985 | + | if cfg.Server.MaxBlobChunkSize != "" { | |||
| 986 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |||
| 987 | + | if err != nil { | |||
| 988 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |||
| 989 | + | } | |||
| 990 | + | maxBlobChunkSize = parsedSize | |||
| 991 | + | } | |||
| 992 | + | ||||
| 993 | + | maxProxyResponseSize := int64(1 << 30) // 1GB default | |||
| 994 | + | if cfg.Server.MaxProxyResponseSize != "" { | |||
| 995 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize) | |||
| 996 | + | if err != nil { | |||
| 997 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize}) | |||
| 998 | + | } | |||
| 999 | + | maxProxyResponseSize = parsedSize | |||
| 1000 | + | } | |||
| 1001 | + | ||||
| 1002 | + | maxConcurrentConns := cfg.Server.MaxConcurrentConns | |||
| 1003 | + | if maxConcurrentConns < 0 { | |||
| 1004 | + | maxConcurrentConns = 10000 // default when explicitly set to -1 | |||
| 1005 | + | } | |||
| 1006 | + | // 0 means no limit (as documented in proxy.Config) | |||
| 1007 | + | ||||
| 1008 | + | return &proxyConfigResult{ | |||
| 1009 | + | proxyConfig: proxy.Config{ | |||
| 1010 | + | RegistryDomain: cfg.Server.RegistryDomain, | |||
| 1011 | + | RegistryPort: cfg.Server.RegistryPort, | |||
| 1012 | + | MaxBodySize: maxProxyBodySize, | |||
| 1013 | + | MaxResponseSize: maxProxyResponseSize, | |||
| 1014 | + | MaxConcurrentConns: maxConcurrentConns, | |||
| 1015 | + | }, | |||
| 1016 | + | maxBlobChunkSize: maxBlobChunkSize, | |||
| 1017 | + | }, nil | |||
| 1018 | + | } | |||
| 1019 | + | ||||
| 986 | // createContainerService creates the container service with configuration. | 1020 | // createContainerService creates the container service with configuration. | ||
| 987 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | 1021 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | ||
| 988 | containerConfig := container.Config{ | 1022 | containerConfig := container.Config{ | ||
| @@ -1083,11 +1117,21 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo | |||||
| 1083 | return | 1117 | return | ||
| 1084 | } | 1118 | } | ||
| 1085 | 1119 | ||||
| 1086 | - | // Update proxy config from viper (reads directly from viper, not memory) | 1120 | + | // Re-unmarshal config and rebuild proxy config to pick up all changes |
| 1087 | - | svc.proxySvc.UpdateConfig(proxy.Config{ | 1121 | + | var reloadCfg Config |
| 1088 | - | RegistryDomain: v.GetString("server.registry_domain"), | 1122 | + | if err := v.Unmarshal(&reloadCfg); err != nil { |
| 1089 | - | RegistryPort: v.GetInt("server.registry_port"), | 1123 | + | log.Error().Err(err).Msg("failed to unmarshal config on reload") |
| 1090 | - | }) | 1124 | + | return |
| 1125 | + | } | |||
| 1126 | + | if reloadCfg.Server.GordonDomain != "" { | |||
| 1127 | + | reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain | |||
| 1128 | + | } | |||
| 1129 | + | reloadedProxy, err := buildProxyConfig(reloadCfg, log) | |||
| 1130 | + | if err != nil { | |||
| 1131 | + | log.Error().Err(err).Msg("failed to parse proxy config on reload") | |||
| 1132 | + | return | |||
| 1133 | + | } | |||
| 1134 | + | svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig) | |||
| 1091 | 1135 | ||||
| 1092 | // Clear proxy target cache to pick up external route changes | 1136 | // Clear proxy target cache to pick up external route changes | ||
| 1093 | if err := svc.proxySvc.RefreshTargets(ctx); err != nil { | 1137 | if err := svc.proxySvc.RefreshTargets(ctx); err != nil { | ||
| @@ -1309,39 +1353,54 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 1309 | } | 1353 | } | ||
| 1310 | 1354 | ||||
| 1311 | shutdown: | 1355 | shutdown: | ||
| 1356 | + | gracefulShutdown(registrySrv, proxySrv, containerSvc, log) | |||
| 1357 | + | return nil | |||
| 1358 | + | } | |||
| 1359 | + | ||||
| 1360 | + | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |||
| 1361 | + | // the container service and cleans up runtime files. | |||
| 1362 | + | func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { | |||
| 1312 | log.Info().Msg("shutting down Gordon...") | 1363 | log.Info().Msg("shutting down Gordon...") | ||
| 1313 | 1364 | ||||
| 1314 | - | if err := containerSvc.Shutdown(ctx); err != nil { | 1365 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 1366 | + | defer shutdownCancel() | |||
| 1367 | + | ||||
| 1368 | + | if err := registrySrv.Shutdown(shutdownCtx); err != nil { | |||
| 1369 | + | log.Warn().Err(err).Msg("registry server shutdown error") | |||
| 1370 | + | } | |||
| 1371 | + | if err := proxySrv.Shutdown(shutdownCtx); err != nil { | |||
| 1372 | + | log.Warn().Err(err).Msg("proxy server shutdown error") | |||
| 1373 | + | } | |||
| 1374 | + | ||||
| 1375 | + | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | |||
| 1315 | log.Warn().Err(err).Msg("error during container shutdown") | 1376 | log.Warn().Err(err).Msg("error during container shutdown") | ||
| 1316 | } | 1377 | } | ||
| 1317 | 1378 | ||||
| 1318 | - | // Clean up internal credentials file | |||
| 1319 | cleanupInternalCredentials() | 1379 | cleanupInternalCredentials() | ||
| 1320 | - | ||||
| 1321 | log.Info().Msg("Gordon stopped") | 1380 | log.Info().Msg("Gordon stopped") | ||
| 1322 | - | return nil | |||
| 1323 | } | 1381 | } | ||
| 1324 | 1382 | ||||
| 1325 | - | // startServer starts an HTTP server, returning a channel that closes once the | 1383 | + | // startServer starts an HTTP server, returning the server instance and a channel |
| 1326 | - | // listening socket is bound. This lets callers wait for the port to be ready | 1384 | + | // that closes once the listening socket is bound. This lets callers wait for the |
| 1327 | - | // before taking actions that depend on it (e.g. auto-start pulling from the | 1385 | + | // port to be ready before taking actions that depend on it (e.g. auto-start |
| 1328 | - | // local registry). | 1386 | + | // pulling from the local registry). The returned *http.Server can be used for |
| 1329 | - | func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} { | 1387 | + | // graceful shutdown. |
| 1388 | + | func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |||
| 1330 | ready := make(chan struct{}) | 1389 | ready := make(chan struct{}) | ||
| 1331 | 1390 | ||||
| 1391 | + | server := &http.Server{ | |||
| 1392 | + | Addr: addr, | |||
| 1393 | + | Handler: handler, | |||
| 1394 | + | ReadHeaderTimeout: 10 * time.Second, | |||
| 1395 | + | ReadTimeout: 5 * time.Minute, | |||
| 1396 | + | WriteTimeout: 5 * time.Minute, | |||
| 1397 | + | IdleTimeout: 120 * time.Second, | |||
| 1398 | + | MaxHeaderBytes: 1 << 20, | |||
| 1399 | + | } | |||
| 1400 | + | ||||
| 1332 | go func() { | 1401 | go func() { | ||
| 1333 | log.Info().Str("address", addr).Msgf("%s server starting", name) | 1402 | log.Info().Str("address", addr).Msgf("%s server starting", name) | ||
| 1334 | 1403 | ||||
| 1335 | - | server := &http.Server{ | |||
| 1336 | - | Addr: addr, | |||
| 1337 | - | Handler: handler, | |||
| 1338 | - | ReadHeaderTimeout: 10 * time.Second, | |||
| 1339 | - | ReadTimeout: 5 * time.Minute, | |||
| 1340 | - | WriteTimeout: 5 * time.Minute, | |||
| 1341 | - | IdleTimeout: 120 * time.Second, | |||
| 1342 | - | MaxHeaderBytes: 1 << 20, | |||
| 1343 | - | } | |||
| 1344 | - | ||||
| 1345 | ln, err := net.Listen("tcp", addr) | 1404 | ln, err := net.Listen("tcp", addr) | ||
| 1346 | if err != nil { | 1405 | if err != nil { | ||
| 1347 | errChan <- fmt.Errorf("%s server error: %w", name, err) | 1406 | errChan <- fmt.Errorf("%s server error: %w", name, err) | ||
| @@ -1354,7 +1413,7 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||||
| 1354 | } | 1413 | } | ||
| 1355 | }() | 1414 | }() | ||
| 1356 | 1415 | ||||
| 1357 | - | return ready | 1416 | + | return server, ready |
| 1358 | } | 1417 | } | ||
| 1359 | 1418 | ||||
| 1360 | // startTLSServer starts an HTTPS server with the provided certificate and key. | 1419 | // startTLSServer starts an HTTPS server with the provided certificate and key. | ||
| @@ -1680,6 +1739,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1680 | v.SetDefault("volumes.prefix", "gordon") | 1739 | v.SetDefault("volumes.prefix", "gordon") | ||
| 1681 | v.SetDefault("volumes.preserve", true) | 1740 | v.SetDefault("volumes.preserve", true) | ||
| 1682 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | 1741 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | ||
| 1742 | + | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |||
| 1683 | v.SetDefault("deploy.readiness_delay", "5s") | 1743 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 1684 | 1744 | ||||
| 1685 | ConfigureViper(v, configPath) | 1745 | ConfigureViper(v, configPath) | ||
M internal/domain/route.go +5 -4
| @@ -9,10 +9,11 @@ type Route struct { | |||
| 9 | 9 | ||
| 10 | 10 | // ProxyTarget represents the destination for proxying requests. | |
| 11 | 11 | type ProxyTarget struct { | |
| 12 | - | Host string | |
| 13 | - | Port int | |
| 14 | - | ContainerID string | |
| 15 | - | Scheme string // "http" or "https" | |
| 12 | + | Host string | |
| 13 | + | Port int | |
| 14 | + | ContainerID string | |
| 15 | + | Scheme string // "http" or "https" | |
| 16 | + | OriginalHost string // Original hostname before DNS resolution (for Host header) | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | // RouteMatch represents the result of matching a request to a route. | |
| @@ -9,10 +9,11 @@ type Route struct { | |||||
| 9 | 9 | ||||
| 10 | // ProxyTarget represents the destination for proxying requests. | 10 | // ProxyTarget represents the destination for proxying requests. | ||
| 11 | type ProxyTarget struct { | 11 | type ProxyTarget struct { | ||
| 12 | - | Host string | 12 | + | Host string |
| 13 | - | Port int | 13 | + | Port int |
| 14 | - | ContainerID string | 14 | + | ContainerID string |
| 15 | - | Scheme string // "http" or "https" | 15 | + | Scheme string // "http" or "https" |
| 16 | + | OriginalHost string // Original hostname before DNS resolution (for Host header) | |||
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | // RouteMatch represents the result of matching a request to a route. | 19 | // RouteMatch represents the result of matching a request to a route. | ||
M internal/usecase/proxy/service.go +153 -29
| @@ -3,7 +3,9 @@ package proxy | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 8 | + | "io" | |
| 7 | 9 | "net" | |
| 8 | 10 | "net/http" | |
| 9 | 11 | "net/http/httputil" | |
| @@ -12,6 +14,7 @@ import ( | |||
| 12 | 14 | "strconv" | |
| 13 | 15 | "strings" | |
| 14 | 16 | "sync" | |
| 17 | + | "sync/atomic" | |
| 15 | 18 | "time" | |
| 16 | 19 | ||
| 17 | 20 | "github.com/bnema/zerowrap" | |
| @@ -37,9 +40,11 @@ var proxyTransport = &http.Transport{ | |||
| 37 | 40 | ||
| 38 | 41 | // Config holds configuration needed by the proxy service. | |
| 39 | 42 | type Config struct { | |
| 40 | - | RegistryDomain string | |
| 41 | - | RegistryPort int | |
| 42 | - | MaxBodySize int64 // Maximum request body size in bytes (0 = no limit) | |
| 43 | + | RegistryDomain string | |
| 44 | + | RegistryPort int | |
| 45 | + | MaxBodySize int64 // Maximum request body size in bytes (0 = no limit) | |
| 46 | + | MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit) | |
| 47 | + | MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit) | |
| 43 | 48 | } | |
| 44 | 49 | ||
| 45 | 50 | // Service implements the ProxyService interface. | |
| @@ -50,6 +55,7 @@ type Service struct { | |||
| 50 | 55 | config Config | |
| 51 | 56 | targets map[string]*domain.ProxyTarget | |
| 52 | 57 | mu sync.RWMutex | |
| 58 | + | activeConns atomic.Int64 // Atomic counter for concurrent connection limiting | |
| 53 | 59 | } | |
| 54 | 60 | ||
| 55 | 61 | // NewService creates a new proxy service. | |
| @@ -75,6 +81,19 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 75 | 81 | cfg := s.config | |
| 76 | 82 | s.mu.RUnlock() | |
| 77 | 83 | ||
| 84 | + | // SECURITY: Limit concurrent connections to prevent resource exhaustion. | |
| 85 | + | // Uses an atomic counter so config reloads don't create a race between | |
| 86 | + | // an old and new semaphore — the counter is shared across reloads. | |
| 87 | + | if cfg.MaxConcurrentConns > 0 { | |
| 88 | + | current := s.activeConns.Add(1) | |
| 89 | + | if current > int64(cfg.MaxConcurrentConns) { | |
| 90 | + | s.activeConns.Add(-1) | |
| 91 | + | proxyError(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | defer s.activeConns.Add(-1) | |
| 95 | + | } | |
| 96 | + | ||
| 78 | 97 | // SECURITY: Limit request body size to prevent resource exhaustion. | |
| 79 | 98 | if cfg.MaxBodySize > 0 { | |
| 80 | 99 | r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize) | |
| @@ -104,7 +123,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 104 | 123 | target, err := s.GetTarget(ctx, r.Host) | |
| 105 | 124 | if err != nil { | |
| 106 | 125 | log.Warn().Err(err).Msg("no route found for domain") | |
| 107 | - | http.NotFound(w, r) | |
| 126 | + | proxyError(w, "404 page not found", http.StatusNotFound) | |
| 108 | 127 | return | |
| 109 | 128 | } | |
| 110 | 129 | ||
| @@ -151,8 +170,12 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 151 | 170 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 152 | 171 | } | |
| 153 | 172 | ||
| 154 | - | // SECURITY: Validate target is not an internal/blocked network (SSRF protection) | |
| 155 | - | if err := ValidateExternalRouteTarget(host); err != nil { | |
| 173 | + | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | |
| 174 | + | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | |
| 175 | + | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |
| 176 | + | // but to a private IP when the proxy connects. | |
| 177 | + | resolvedIP, err := ResolveAndValidateHost(host) | |
| 178 | + | if err != nil { | |
| 156 | 179 | log.Warn(). | |
| 157 | 180 | Err(err). | |
| 158 | 181 | Str("host", host). | |
| @@ -161,11 +184,19 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 161 | 184 | return nil, err | |
| 162 | 185 | } | |
| 163 | 186 | ||
| 187 | + | // Preserve the original hostname for the Host header so virtual-hosted | |
| 188 | + | // upstreams work correctly. The resolved IP is used for dialing only. | |
| 189 | + | var originalHost string | |
| 190 | + | if resolvedIP != host { | |
| 191 | + | originalHost = host | |
| 192 | + | } | |
| 193 | + | ||
| 164 | 194 | target := &domain.ProxyTarget{ | |
| 165 | - | Host: host, | |
| 166 | - | Port: port, | |
| 167 | - | ContainerID: "", // Not a container | |
| 168 | - | Scheme: "http", | |
| 195 | + | Host: resolvedIP, | |
| 196 | + | Port: port, | |
| 197 | + | ContainerID: "", // Not a container | |
| 198 | + | Scheme: "http", | |
| 199 | + | OriginalHost: originalHost, | |
| 169 | 200 | } | |
| 170 | 201 | ||
| 171 | 202 | // Cache external route target | |
| @@ -285,19 +316,93 @@ func (s *Service) RefreshTargets(ctx context.Context) error { | |||
| 285 | 316 | } | |
| 286 | 317 | ||
| 287 | 318 | // UpdateConfig updates the service configuration. | |
| 319 | + | // Connection limiting uses an atomic counter checked against config, so | |
| 320 | + | // changing MaxConcurrentConns takes effect immediately without any race. | |
| 288 | 321 | func (s *Service) UpdateConfig(config Config) { | |
| 289 | 322 | s.mu.Lock() | |
| 290 | 323 | s.config = config | |
| 291 | 324 | s.mu.Unlock() | |
| 292 | 325 | } | |
| 293 | 326 | ||
| 327 | + | // modifyResponse returns a function that modifies backend responses. | |
| 328 | + | // It adds proxy headers and enforces the response size limit if configured. | |
| 329 | + | func (s *Service) modifyResponse(cfg Config) func(*http.Response) error { | |
| 330 | + | return func(resp *http.Response) error { | |
| 331 | + | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 332 | + | ||
| 333 | + | // SECURITY: Enforce response size limit to prevent memory exhaustion | |
| 334 | + | // from malicious or buggy backends streaming unbounded data. | |
| 335 | + | if cfg.MaxResponseSize > 0 { | |
| 336 | + | if resp.ContentLength > cfg.MaxResponseSize { | |
| 337 | + | resp.Body.Close() | |
| 338 | + | const msg = "Response Too Large" | |
| 339 | + | resp.StatusCode = http.StatusBadGateway | |
| 340 | + | resp.Body = io.NopCloser(strings.NewReader(msg)) | |
| 341 | + | resp.ContentLength = int64(len(msg)) | |
| 342 | + | resp.Header.Set("Content-Type", "text/plain") | |
| 343 | + | resp.Header.Set("Cache-Control", "no-store") | |
| 344 | + | resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") | |
| 345 | + | // Ensure framing/encoding headers match the substituted body | |
| 346 | + | resp.Header.Del("Transfer-Encoding") | |
| 347 | + | resp.Header.Del("Content-Encoding") | |
| 348 | + | resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) | |
| 349 | + | return nil | |
| 350 | + | } | |
| 351 | + | // For chunked/streaming responses where Content-Length is unknown, | |
| 352 | + | // wrap with LimitReader to enforce the cap. | |
| 353 | + | if resp.ContentLength < 0 { | |
| 354 | + | resp.Body = &limitedReadCloser{ | |
| 355 | + | ReadCloser: resp.Body, | |
| 356 | + | remaining: cfg.MaxResponseSize, | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | return nil | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // limitedReadCloser wraps an io.ReadCloser with a byte limit. | |
| 366 | + | // When the limit is exceeded, subsequent reads return an error. | |
| 367 | + | type limitedReadCloser struct { | |
| 368 | + | io.ReadCloser | |
| 369 | + | remaining int64 | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func (l *limitedReadCloser) Read(p []byte) (int, error) { | |
| 373 | + | if l.remaining <= 0 { | |
| 374 | + | // Close the underlying body to release upstream connection/resources | |
| 375 | + | l.Close() | |
| 376 | + | return 0, fmt.Errorf("response body exceeded maximum size limit") | |
| 377 | + | } | |
| 378 | + | if int64(len(p)) > l.remaining { | |
| 379 | + | p = p[:l.remaining] | |
| 380 | + | } | |
| 381 | + | n, err := l.ReadCloser.Read(p) | |
| 382 | + | l.remaining -= int64(n) | |
| 383 | + | return n, err | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // proxyError writes an error response with security headers appropriate for | |
| 387 | + | // proxy-generated error pages. CSP is set here (not in global middleware) | |
| 388 | + | // because Gordon proxies arbitrary web apps and a blanket CSP would break them. | |
| 389 | + | func proxyError(w http.ResponseWriter, msg string, code int) { | |
| 390 | + | w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") | |
| 391 | + | w.Header().Set("Cache-Control", "no-store") | |
| 392 | + | http.Error(w, msg, code) | |
| 393 | + | } | |
| 394 | + | ||
| 294 | 395 | // Helper methods | |
| 295 | 396 | ||
| 296 | 397 | // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent | |
| 297 | 398 | // hop-by-hop header attacks. A malicious client could send "Connection: Authorization" | |
| 298 | 399 | // to strip the Authorization header when using the default Director. Rewrite processes | |
| 299 | 400 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | |
| 300 | - | func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy { | |
| 401 | + | // | |
| 402 | + | // originalHost, when non-empty, overrides the Host header sent to the backend. | |
| 403 | + | // This is used for DNS-pinned targets where the proxy dials an IP but needs to | |
| 404 | + | // send the original hostname for virtual-hosted upstreams. | |
| 405 | + | func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy { | |
| 301 | 406 | return &httputil.ReverseProxy{ | |
| 302 | 407 | Rewrite: func(pr *httputil.ProxyRequest) { | |
| 303 | 408 | pr.SetURL(targetURL) | |
| @@ -307,7 +412,13 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, | |||
| 307 | 412 | // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection | |
| 308 | 413 | // to trick backends into thinking the connection is secure. | |
| 309 | 414 | pr.SetXForwarded() | |
| 310 | - | pr.Out.Host = targetURL.Host | |
| 415 | + | if originalHost != "" { | |
| 416 | + | // DNS pinning: dial the resolved IP but send the original hostname | |
| 417 | + | // so virtual-hosted upstreams route the request correctly. | |
| 418 | + | pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port()) | |
| 419 | + | } else { | |
| 420 | + | pr.Out.Host = targetURL.Host | |
| 421 | + | } | |
| 311 | 422 | }, | |
| 312 | 423 | Transport: proxyTransport, | |
| 313 | 424 | ErrorHandler: errorHandler, | |
| @@ -316,32 +427,38 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, | |||
| 316 | 427 | } | |
| 317 | 428 | ||
| 318 | 429 | func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) { | |
| 430 | + | s.mu.RLock() | |
| 431 | + | cfg := s.config | |
| 432 | + | s.mu.RUnlock() | |
| 433 | + | ||
| 319 | 434 | log := zerowrap.FromCtx(r.Context()) | |
| 320 | 435 | ||
| 321 | - | targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port)) | |
| 436 | + | // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically) | |
| 437 | + | targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port)))) | |
| 322 | 438 | if err != nil { | |
| 323 | 439 | log.WrapErr(err, "failed to parse target URL") | |
| 324 | - | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 440 | + | proxyError(w, "Internal Server Error", http.StatusInternalServerError) | |
| 325 | 441 | return | |
| 326 | 442 | } | |
| 327 | 443 | ||
| 328 | 444 | log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target") | |
| 329 | 445 | ||
| 330 | - | proxy := newReverseProxy(targetURL, | |
| 446 | + | // If DNS pinning resolved hostname to IP, preserve the original hostname | |
| 447 | + | // for the Host header so virtual-hosted upstreams work correctly. | |
| 448 | + | originalHost := target.OriginalHost | |
| 449 | + | ||
| 450 | + | proxy := newReverseProxy(targetURL, originalHost, | |
| 331 | 451 | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 332 | - | // Check if this is a MaxBytesError (request body too large) | |
| 333 | - | if err.Error() == "http: request body too large" { | |
| 452 | + | var maxBytesErr *http.MaxBytesError | |
| 453 | + | if errors.As(err, &maxBytesErr) { | |
| 334 | 454 | log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large") | |
| 335 | - | http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | |
| 455 | + | proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | |
| 336 | 456 | return | |
| 337 | 457 | } | |
| 338 | 458 | log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed") | |
| 339 | - | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 340 | - | }, | |
| 341 | - | func(resp *http.Response) error { | |
| 342 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 343 | - | return nil | |
| 459 | + | proxyError(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 344 | 460 | }, | |
| 461 | + | s.modifyResponse(cfg), | |
| 345 | 462 | ) | |
| 346 | 463 | ||
| 347 | 464 | log.Debug(). | |
| @@ -352,6 +469,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 352 | 469 | proxy.ServeHTTP(w, r) | |
| 353 | 470 | } | |
| 354 | 471 | ||
| 472 | + | // proxyToRegistry forwards requests to the local registry HTTP server. | |
| 473 | + | // SECURITY: This uses http://localhost:{port} which is safe because the registry | |
| 474 | + | // runs on the same host and traffic never leaves the loopback interface. If Gordon | |
| 475 | + | // ever supports running the registry on a separate host, this must use TLS with | |
| 476 | + | // certificate verification. | |
| 355 | 477 | func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |
| 356 | 478 | // Copy config under RLock to avoid data race with UpdateConfig | |
| 357 | 479 | s.mu.RLock() | |
| @@ -363,20 +485,20 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 363 | 485 | targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort)) | |
| 364 | 486 | if err != nil { | |
| 365 | 487 | log.WrapErr(err, "failed to parse registry target URL") | |
| 366 | - | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 488 | + | proxyError(w, "Internal Server Error", http.StatusInternalServerError) | |
| 367 | 489 | return | |
| 368 | 490 | } | |
| 369 | 491 | ||
| 370 | - | proxy := newReverseProxy(targetURL, | |
| 492 | + | proxy := newReverseProxy(targetURL, "", | |
| 371 | 493 | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 372 | - | // Check if this is a MaxBytesError (request body too large) | |
| 373 | - | if err.Error() == "http: request body too large" { | |
| 494 | + | var maxBytesErr *http.MaxBytesError | |
| 495 | + | if errors.As(err, &maxBytesErr) { | |
| 374 | 496 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | |
| 375 | - | http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | |
| 497 | + | proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | |
| 376 | 498 | return | |
| 377 | 499 | } | |
| 378 | 500 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | |
| 379 | - | http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 501 | + | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 380 | 502 | }, | |
| 381 | 503 | func(resp *http.Response) error { | |
| 382 | 504 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| @@ -390,6 +512,8 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 390 | 512 | resp.Header.Del("X-XSS-Protection") | |
| 391 | 513 | resp.Header.Del("Referrer-Policy") | |
| 392 | 514 | resp.Header.Del("Permissions-Policy") | |
| 515 | + | resp.Header.Del("Content-Security-Policy") | |
| 516 | + | resp.Header.Del("Strict-Transport-Security") | |
| 393 | 517 | ||
| 394 | 518 | return nil | |
| 395 | 519 | }, | |
| @@ -3,7 +3,9 @@ package proxy | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io" | |||
| 7 | "net" | 9 | "net" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| 9 | "net/http/httputil" | 11 | "net/http/httputil" | ||
| @@ -12,6 +14,7 @@ import ( | |||||
| 12 | "strconv" | 14 | "strconv" | ||
| 13 | "strings" | 15 | "strings" | ||
| 14 | "sync" | 16 | "sync" | ||
| 17 | + | "sync/atomic" | |||
| 15 | "time" | 18 | "time" | ||
| 16 | 19 | ||||
| 17 | "github.com/bnema/zerowrap" | 20 | "github.com/bnema/zerowrap" | ||
| @@ -37,9 +40,11 @@ var proxyTransport = &http.Transport{ | |||||
| 37 | 40 | ||||
| 38 | // Config holds configuration needed by the proxy service. | 41 | // Config holds configuration needed by the proxy service. | ||
| 39 | type Config struct { | 42 | type Config struct { | ||
| 40 | - | RegistryDomain string | 43 | + | RegistryDomain string |
| 41 | - | RegistryPort int | 44 | + | RegistryPort int |
| 42 | - | MaxBodySize int64 // Maximum request body size in bytes (0 = no limit) | 45 | + | MaxBodySize int64 // Maximum request body size in bytes (0 = no limit) |
| 46 | + | MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit) | |||
| 47 | + | MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit) | |||
| 43 | } | 48 | } | ||
| 44 | 49 | ||||
| 45 | // Service implements the ProxyService interface. | 50 | // Service implements the ProxyService interface. | ||
| @@ -50,6 +55,7 @@ type Service struct { | |||||
| 50 | config Config | 55 | config Config | ||
| 51 | targets map[string]*domain.ProxyTarget | 56 | targets map[string]*domain.ProxyTarget | ||
| 52 | mu sync.RWMutex | 57 | mu sync.RWMutex | ||
| 58 | + | activeConns atomic.Int64 // Atomic counter for concurrent connection limiting | |||
| 53 | } | 59 | } | ||
| 54 | 60 | ||||
| 55 | // NewService creates a new proxy service. | 61 | // NewService creates a new proxy service. | ||
| @@ -75,6 +81,19 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 75 | cfg := s.config | 81 | cfg := s.config | ||
| 76 | s.mu.RUnlock() | 82 | s.mu.RUnlock() | ||
| 77 | 83 | ||||
| 84 | + | // SECURITY: Limit concurrent connections to prevent resource exhaustion. | |||
| 85 | + | // Uses an atomic counter so config reloads don't create a race between | |||
| 86 | + | // an old and new semaphore — the counter is shared across reloads. | |||
| 87 | + | if cfg.MaxConcurrentConns > 0 { | |||
| 88 | + | current := s.activeConns.Add(1) | |||
| 89 | + | if current > int64(cfg.MaxConcurrentConns) { | |||
| 90 | + | s.activeConns.Add(-1) | |||
| 91 | + | proxyError(w, "Service Unavailable", http.StatusServiceUnavailable) | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | defer s.activeConns.Add(-1) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 78 | // SECURITY: Limit request body size to prevent resource exhaustion. | 97 | // SECURITY: Limit request body size to prevent resource exhaustion. | ||
| 79 | if cfg.MaxBodySize > 0 { | 98 | if cfg.MaxBodySize > 0 { | ||
| 80 | r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize) | 99 | r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize) | ||
| @@ -104,7 +123,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 104 | target, err := s.GetTarget(ctx, r.Host) | 123 | target, err := s.GetTarget(ctx, r.Host) | ||
| 105 | if err != nil { | 124 | if err != nil { | ||
| 106 | log.Warn().Err(err).Msg("no route found for domain") | 125 | log.Warn().Err(err).Msg("no route found for domain") | ||
| 107 | - | http.NotFound(w, r) | 126 | + | proxyError(w, "404 page not found", http.StatusNotFound) |
| 108 | return | 127 | return | ||
| 109 | } | 128 | } | ||
| 110 | 129 | ||||
| @@ -151,8 +170,12 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 151 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | 170 | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | ||
| 152 | } | 171 | } | ||
| 153 | 172 | ||||
| 154 | - | // SECURITY: Validate target is not an internal/blocked network (SSRF protection) | 173 | + | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked |
| 155 | - | if err := ValidateExternalRouteTarget(host); err != nil { | 174 | + | // network. We use the resolved IP as the proxy target to prevent DNS rebinding |
| 175 | + | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |||
| 176 | + | // but to a private IP when the proxy connects. | |||
| 177 | + | resolvedIP, err := ResolveAndValidateHost(host) | |||
| 178 | + | if err != nil { | |||
| 156 | log.Warn(). | 179 | log.Warn(). | ||
| 157 | Err(err). | 180 | Err(err). | ||
| 158 | Str("host", host). | 181 | Str("host", host). | ||
| @@ -161,11 +184,19 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 161 | return nil, err | 184 | return nil, err | ||
| 162 | } | 185 | } | ||
| 163 | 186 | ||||
| 187 | + | // Preserve the original hostname for the Host header so virtual-hosted | |||
| 188 | + | // upstreams work correctly. The resolved IP is used for dialing only. | |||
| 189 | + | var originalHost string | |||
| 190 | + | if resolvedIP != host { | |||
| 191 | + | originalHost = host | |||
| 192 | + | } | |||
| 193 | + | ||||
| 164 | target := &domain.ProxyTarget{ | 194 | target := &domain.ProxyTarget{ | ||
| 165 | - | Host: host, | 195 | + | Host: resolvedIP, |
| 166 | - | Port: port, | 196 | + | Port: port, |
| 167 | - | ContainerID: "", // Not a container | 197 | + | ContainerID: "", // Not a container |
| 168 | - | Scheme: "http", | 198 | + | Scheme: "http", |
| 199 | + | OriginalHost: originalHost, | |||
| 169 | } | 200 | } | ||
| 170 | 201 | ||||
| 171 | // Cache external route target | 202 | // Cache external route target | ||
| @@ -285,19 +316,93 @@ func (s *Service) RefreshTargets(ctx context.Context) error { | |||||
| 285 | } | 316 | } | ||
| 286 | 317 | ||||
| 287 | // UpdateConfig updates the service configuration. | 318 | // UpdateConfig updates the service configuration. | ||
| 319 | + | // Connection limiting uses an atomic counter checked against config, so | |||
| 320 | + | // changing MaxConcurrentConns takes effect immediately without any race. | |||
| 288 | func (s *Service) UpdateConfig(config Config) { | 321 | func (s *Service) UpdateConfig(config Config) { | ||
| 289 | s.mu.Lock() | 322 | s.mu.Lock() | ||
| 290 | s.config = config | 323 | s.config = config | ||
| 291 | s.mu.Unlock() | 324 | s.mu.Unlock() | ||
| 292 | } | 325 | } | ||
| 293 | 326 | ||||
| 327 | + | // modifyResponse returns a function that modifies backend responses. | |||
| 328 | + | // It adds proxy headers and enforces the response size limit if configured. | |||
| 329 | + | func (s *Service) modifyResponse(cfg Config) func(*http.Response) error { | |||
| 330 | + | return func(resp *http.Response) error { | |||
| 331 | + | resp.Header.Set("X-Proxied-By", "Gordon") | |||
| 332 | + | ||||
| 333 | + | // SECURITY: Enforce response size limit to prevent memory exhaustion | |||
| 334 | + | // from malicious or buggy backends streaming unbounded data. | |||
| 335 | + | if cfg.MaxResponseSize > 0 { | |||
| 336 | + | if resp.ContentLength > cfg.MaxResponseSize { | |||
| 337 | + | resp.Body.Close() | |||
| 338 | + | const msg = "Response Too Large" | |||
| 339 | + | resp.StatusCode = http.StatusBadGateway | |||
| 340 | + | resp.Body = io.NopCloser(strings.NewReader(msg)) | |||
| 341 | + | resp.ContentLength = int64(len(msg)) | |||
| 342 | + | resp.Header.Set("Content-Type", "text/plain") | |||
| 343 | + | resp.Header.Set("Cache-Control", "no-store") | |||
| 344 | + | resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") | |||
| 345 | + | // Ensure framing/encoding headers match the substituted body | |||
| 346 | + | resp.Header.Del("Transfer-Encoding") | |||
| 347 | + | resp.Header.Del("Content-Encoding") | |||
| 348 | + | resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) | |||
| 349 | + | return nil | |||
| 350 | + | } | |||
| 351 | + | // For chunked/streaming responses where Content-Length is unknown, | |||
| 352 | + | // wrap with LimitReader to enforce the cap. | |||
| 353 | + | if resp.ContentLength < 0 { | |||
| 354 | + | resp.Body = &limitedReadCloser{ | |||
| 355 | + | ReadCloser: resp.Body, | |||
| 356 | + | remaining: cfg.MaxResponseSize, | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | return nil | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // limitedReadCloser wraps an io.ReadCloser with a byte limit. | |||
| 366 | + | // When the limit is exceeded, subsequent reads return an error. | |||
| 367 | + | type limitedReadCloser struct { | |||
| 368 | + | io.ReadCloser | |||
| 369 | + | remaining int64 | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | func (l *limitedReadCloser) Read(p []byte) (int, error) { | |||
| 373 | + | if l.remaining <= 0 { | |||
| 374 | + | // Close the underlying body to release upstream connection/resources | |||
| 375 | + | l.Close() | |||
| 376 | + | return 0, fmt.Errorf("response body exceeded maximum size limit") | |||
| 377 | + | } | |||
| 378 | + | if int64(len(p)) > l.remaining { | |||
| 379 | + | p = p[:l.remaining] | |||
| 380 | + | } | |||
| 381 | + | n, err := l.ReadCloser.Read(p) | |||
| 382 | + | l.remaining -= int64(n) | |||
| 383 | + | return n, err | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // proxyError writes an error response with security headers appropriate for | |||
| 387 | + | // proxy-generated error pages. CSP is set here (not in global middleware) | |||
| 388 | + | // because Gordon proxies arbitrary web apps and a blanket CSP would break them. | |||
| 389 | + | func proxyError(w http.ResponseWriter, msg string, code int) { | |||
| 390 | + | w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") | |||
| 391 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 392 | + | http.Error(w, msg, code) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 294 | // Helper methods | 395 | // Helper methods | ||
| 295 | 396 | ||||
| 296 | // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent | 397 | // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent | ||
| 297 | // hop-by-hop header attacks. A malicious client could send "Connection: Authorization" | 398 | // hop-by-hop header attacks. A malicious client could send "Connection: Authorization" | ||
| 298 | // to strip the Authorization header when using the default Director. Rewrite processes | 399 | // to strip the Authorization header when using the default Director. Rewrite processes | ||
| 299 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | 400 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | ||
| 300 | - | func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy { | 401 | + | // |
| 402 | + | // originalHost, when non-empty, overrides the Host header sent to the backend. | |||
| 403 | + | // This is used for DNS-pinned targets where the proxy dials an IP but needs to | |||
| 404 | + | // send the original hostname for virtual-hosted upstreams. | |||
| 405 | + | func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy { | |||
| 301 | return &httputil.ReverseProxy{ | 406 | return &httputil.ReverseProxy{ | ||
| 302 | Rewrite: func(pr *httputil.ProxyRequest) { | 407 | Rewrite: func(pr *httputil.ProxyRequest) { | ||
| 303 | pr.SetURL(targetURL) | 408 | pr.SetURL(targetURL) | ||
| @@ -307,7 +412,13 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, | |||||
| 307 | // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection | 412 | // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection | ||
| 308 | // to trick backends into thinking the connection is secure. | 413 | // to trick backends into thinking the connection is secure. | ||
| 309 | pr.SetXForwarded() | 414 | pr.SetXForwarded() | ||
| 310 | - | pr.Out.Host = targetURL.Host | 415 | + | if originalHost != "" { |
| 416 | + | // DNS pinning: dial the resolved IP but send the original hostname | |||
| 417 | + | // so virtual-hosted upstreams route the request correctly. | |||
| 418 | + | pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port()) | |||
| 419 | + | } else { | |||
| 420 | + | pr.Out.Host = targetURL.Host | |||
| 421 | + | } | |||
| 311 | }, | 422 | }, | ||
| 312 | Transport: proxyTransport, | 423 | Transport: proxyTransport, | ||
| 313 | ErrorHandler: errorHandler, | 424 | ErrorHandler: errorHandler, | ||
| @@ -316,32 +427,38 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, | |||||
| 316 | } | 427 | } | ||
| 317 | 428 | ||||
| 318 | func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) { | 429 | func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) { | ||
| 430 | + | s.mu.RLock() | |||
| 431 | + | cfg := s.config | |||
| 432 | + | s.mu.RUnlock() | |||
| 433 | + | ||||
| 319 | log := zerowrap.FromCtx(r.Context()) | 434 | log := zerowrap.FromCtx(r.Context()) | ||
| 320 | 435 | ||||
| 321 | - | targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port)) | 436 | + | // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically) |
| 437 | + | targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port)))) | |||
| 322 | if err != nil { | 438 | if err != nil { | ||
| 323 | log.WrapErr(err, "failed to parse target URL") | 439 | log.WrapErr(err, "failed to parse target URL") | ||
| 324 | - | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | 440 | + | proxyError(w, "Internal Server Error", http.StatusInternalServerError) |
| 325 | return | 441 | return | ||
| 326 | } | 442 | } | ||
| 327 | 443 | ||||
| 328 | log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target") | 444 | log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target") | ||
| 329 | 445 | ||||
| 330 | - | proxy := newReverseProxy(targetURL, | 446 | + | // If DNS pinning resolved hostname to IP, preserve the original hostname |
| 447 | + | // for the Host header so virtual-hosted upstreams work correctly. | |||
| 448 | + | originalHost := target.OriginalHost | |||
| 449 | + | ||||
| 450 | + | proxy := newReverseProxy(targetURL, originalHost, | |||
| 331 | func(w http.ResponseWriter, _ *http.Request, err error) { | 451 | func(w http.ResponseWriter, _ *http.Request, err error) { | ||
| 332 | - | // Check if this is a MaxBytesError (request body too large) | 452 | + | var maxBytesErr *http.MaxBytesError |
| 333 | - | if err.Error() == "http: request body too large" { | 453 | + | if errors.As(err, &maxBytesErr) { |
| 334 | log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large") | 454 | log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large") | ||
| 335 | - | http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | 455 | + | proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) |
| 336 | return | 456 | return | ||
| 337 | } | 457 | } | ||
| 338 | log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed") | 458 | log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed") | ||
| 339 | - | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | 459 | + | proxyError(w, "Service Unavailable", http.StatusServiceUnavailable) |
| 340 | - | }, | |||
| 341 | - | func(resp *http.Response) error { | |||
| 342 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |||
| 343 | - | return nil | |||
| 344 | }, | 460 | }, | ||
| 461 | + | s.modifyResponse(cfg), | |||
| 345 | ) | 462 | ) | ||
| 346 | 463 | ||||
| 347 | log.Debug(). | 464 | log.Debug(). | ||
| @@ -352,6 +469,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||||
| 352 | proxy.ServeHTTP(w, r) | 469 | proxy.ServeHTTP(w, r) | ||
| 353 | } | 470 | } | ||
| 354 | 471 | ||||
| 472 | + | // proxyToRegistry forwards requests to the local registry HTTP server. | |||
| 473 | + | // SECURITY: This uses http://localhost:{port} which is safe because the registry | |||
| 474 | + | // runs on the same host and traffic never leaves the loopback interface. If Gordon | |||
| 475 | + | // ever supports running the registry on a separate host, this must use TLS with | |||
| 476 | + | // certificate verification. | |||
| 355 | func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | 477 | func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | ||
| 356 | // Copy config under RLock to avoid data race with UpdateConfig | 478 | // Copy config under RLock to avoid data race with UpdateConfig | ||
| 357 | s.mu.RLock() | 479 | s.mu.RLock() | ||
| @@ -363,20 +485,20 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 363 | targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort)) | 485 | targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort)) | ||
| 364 | if err != nil { | 486 | if err != nil { | ||
| 365 | log.WrapErr(err, "failed to parse registry target URL") | 487 | log.WrapErr(err, "failed to parse registry target URL") | ||
| 366 | - | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | 488 | + | proxyError(w, "Internal Server Error", http.StatusInternalServerError) |
| 367 | return | 489 | return | ||
| 368 | } | 490 | } | ||
| 369 | 491 | ||||
| 370 | - | proxy := newReverseProxy(targetURL, | 492 | + | proxy := newReverseProxy(targetURL, "", |
| 371 | func(w http.ResponseWriter, _ *http.Request, err error) { | 493 | func(w http.ResponseWriter, _ *http.Request, err error) { | ||
| 372 | - | // Check if this is a MaxBytesError (request body too large) | 494 | + | var maxBytesErr *http.MaxBytesError |
| 373 | - | if err.Error() == "http: request body too large" { | 495 | + | if errors.As(err, &maxBytesErr) { |
| 374 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | 496 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | ||
| 375 | - | http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | 497 | + | proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) |
| 376 | return | 498 | return | ||
| 377 | } | 499 | } | ||
| 378 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | 500 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | ||
| 379 | - | http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable) | 501 | + | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) |
| 380 | }, | 502 | }, | ||
| 381 | func(resp *http.Response) error { | 503 | func(resp *http.Response) error { | ||
| 382 | resp.Header.Set("X-Proxied-By", "Gordon") | 504 | resp.Header.Set("X-Proxied-By", "Gordon") | ||
| @@ -390,6 +512,8 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 390 | resp.Header.Del("X-XSS-Protection") | 512 | resp.Header.Del("X-XSS-Protection") | ||
| 391 | resp.Header.Del("Referrer-Policy") | 513 | resp.Header.Del("Referrer-Policy") | ||
| 392 | resp.Header.Del("Permissions-Policy") | 514 | resp.Header.Del("Permissions-Policy") | ||
| 515 | + | resp.Header.Del("Content-Security-Policy") | |||
| 516 | + | resp.Header.Del("Strict-Transport-Security") | |||
| 393 | 517 | ||||
| 394 | return nil | 518 | return nil | ||
| 395 | }, | 519 | }, | ||
M internal/usecase/proxy/service_test.go +47 -0
| @@ -397,6 +397,53 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) { | |||
| 397 | 397 | assert.NoError(t, err) | |
| 398 | 398 | } | |
| 399 | 399 | ||
| 400 | + | func TestService_ConcurrentConnectionLimit(t *testing.T) { | |
| 401 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 402 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 403 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 404 | + | ||
| 405 | + | // Allow only 1 concurrent connection | |
| 406 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |
| 407 | + | MaxConcurrentConns: 1, | |
| 408 | + | }) | |
| 409 | + | ||
| 410 | + | assert.Equal(t, 1, svc.config.MaxConcurrentConns) | |
| 411 | + | assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0") | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) { | |
| 415 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 416 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 417 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 418 | + | ||
| 419 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |
| 420 | + | MaxConcurrentConns: 0, | |
| 421 | + | }) | |
| 422 | + | ||
| 423 | + | assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit") | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) { | |
| 427 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 428 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 429 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 430 | + | ||
| 431 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |
| 432 | + | MaxConcurrentConns: 1, | |
| 433 | + | }) | |
| 434 | + | ||
| 435 | + | // Simulate an in-flight connection by incrementing the atomic counter | |
| 436 | + | svc.activeConns.Add(1) | |
| 437 | + | defer svc.activeConns.Add(-1) | |
| 438 | + | ||
| 439 | + | // Next request should get 503 | |
| 440 | + | req := httptest.NewRequest("GET", "http://example.com/test", nil) | |
| 441 | + | w := httptest.NewRecorder() | |
| 442 | + | svc.ServeHTTP(w, req) | |
| 443 | + | ||
| 444 | + | assert.Equal(t, 503, w.Code) | |
| 445 | + | } | |
| 446 | + | ||
| 400 | 447 | func TestServeHTTP_MaxBodySize(t *testing.T) { | |
| 401 | 448 | tests := []struct { | |
| 402 | 449 | name string | |
| @@ -397,6 +397,53 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) { | |||||
| 397 | assert.NoError(t, err) | 397 | assert.NoError(t, err) | ||
| 398 | } | 398 | } | ||
| 399 | 399 | ||||
| 400 | + | func TestService_ConcurrentConnectionLimit(t *testing.T) { | |||
| 401 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 402 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 403 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 404 | + | ||||
| 405 | + | // Allow only 1 concurrent connection | |||
| 406 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |||
| 407 | + | MaxConcurrentConns: 1, | |||
| 408 | + | }) | |||
| 409 | + | ||||
| 410 | + | assert.Equal(t, 1, svc.config.MaxConcurrentConns) | |||
| 411 | + | assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0") | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) { | |||
| 415 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 416 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 417 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 418 | + | ||||
| 419 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |||
| 420 | + | MaxConcurrentConns: 0, | |||
| 421 | + | }) | |||
| 422 | + | ||||
| 423 | + | assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit") | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) { | |||
| 427 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 428 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 429 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 430 | + | ||||
| 431 | + | svc := NewService(runtime, containerSvc, configSvc, Config{ | |||
| 432 | + | MaxConcurrentConns: 1, | |||
| 433 | + | }) | |||
| 434 | + | ||||
| 435 | + | // Simulate an in-flight connection by incrementing the atomic counter | |||
| 436 | + | svc.activeConns.Add(1) | |||
| 437 | + | defer svc.activeConns.Add(-1) | |||
| 438 | + | ||||
| 439 | + | // Next request should get 503 | |||
| 440 | + | req := httptest.NewRequest("GET", "http://example.com/test", nil) | |||
| 441 | + | w := httptest.NewRecorder() | |||
| 442 | + | svc.ServeHTTP(w, req) | |||
| 443 | + | ||||
| 444 | + | assert.Equal(t, 503, w.Code) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 400 | func TestServeHTTP_MaxBodySize(t *testing.T) { | 447 | func TestServeHTTP_MaxBodySize(t *testing.T) { | ||
| 401 | tests := []struct { | 448 | tests := []struct { | ||
| 402 | name string | 449 | name string | ||
M internal/usecase/proxy/ssrf.go +38 -0
| @@ -115,3 +115,41 @@ func ValidateExternalRouteTarget(host string) error { | |||
| 115 | 115 | } | |
| 116 | 116 | return nil | |
| 117 | 117 | } | |
| 118 | + | ||
| 119 | + | // ResolveAndValidateHost resolves DNS for a hostname and returns the first | |
| 120 | + | // resolved IP that is not in a blocked CIDR range. If all resolved IPs are | |
| 121 | + | // blocked, returns ErrSSRFBlocked. For raw IP addresses, validates and returns | |
| 122 | + | // them directly. | |
| 123 | + | // | |
| 124 | + | // SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding | |
| 125 | + | // attacks where a hostname resolves to a public IP during validation but to a | |
| 126 | + | // private IP when the proxy actually connects. | |
| 127 | + | func ResolveAndValidateHost(host string) (string, error) { | |
| 128 | + | initBlockedCIDRs() | |
| 129 | + | ||
| 130 | + | // If already an IP address, validate and return directly | |
| 131 | + | if ip := net.ParseIP(host); ip != nil { | |
| 132 | + | if isBlockedIP(ip) { | |
| 133 | + | return "", domain.ErrSSRFBlocked | |
| 134 | + | } | |
| 135 | + | return host, nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Resolve DNS | |
| 139 | + | ips, err := net.LookupIP(host) | |
| 140 | + | if err != nil { | |
| 141 | + | return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err) | |
| 142 | + | } | |
| 143 | + | if len(ips) == 0 { | |
| 144 | + | return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Find the first non-blocked IP | |
| 148 | + | for _, ip := range ips { | |
| 149 | + | if !isBlockedIP(ip) { | |
| 150 | + | return ip.String(), nil | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return "", domain.ErrSSRFBlocked | |
| 155 | + | } | |
| @@ -115,3 +115,41 @@ func ValidateExternalRouteTarget(host string) error { | |||||
| 115 | } | 115 | } | ||
| 116 | return nil | 116 | return nil | ||
| 117 | } | 117 | } | ||
| 118 | + | ||||
| 119 | + | // ResolveAndValidateHost resolves DNS for a hostname and returns the first | |||
| 120 | + | // resolved IP that is not in a blocked CIDR range. If all resolved IPs are | |||
| 121 | + | // blocked, returns ErrSSRFBlocked. For raw IP addresses, validates and returns | |||
| 122 | + | // them directly. | |||
| 123 | + | // | |||
| 124 | + | // SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding | |||
| 125 | + | // attacks where a hostname resolves to a public IP during validation but to a | |||
| 126 | + | // private IP when the proxy actually connects. | |||
| 127 | + | func ResolveAndValidateHost(host string) (string, error) { | |||
| 128 | + | initBlockedCIDRs() | |||
| 129 | + | ||||
| 130 | + | // If already an IP address, validate and return directly | |||
| 131 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 132 | + | if isBlockedIP(ip) { | |||
| 133 | + | return "", domain.ErrSSRFBlocked | |||
| 134 | + | } | |||
| 135 | + | return host, nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Resolve DNS | |||
| 139 | + | ips, err := net.LookupIP(host) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err) | |||
| 142 | + | } | |||
| 143 | + | if len(ips) == 0 { | |||
| 144 | + | return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Find the first non-blocked IP | |||
| 148 | + | for _, ip := range ips { | |||
| 149 | + | if !isBlockedIP(ip) { | |||
| 150 | + | return ip.String(), nil | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return "", domain.ErrSSRFBlocked | |||
| 155 | + | } | |||
M internal/usecase/proxy/ssrf_test.go +38 -0
| @@ -160,6 +160,44 @@ func TestValidateExternalRouteTarget(t *testing.T) { | |||
| 160 | 160 | } | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | + | func TestResolveAndValidateHost_IPAddresses(t *testing.T) { | |
| 164 | + | tests := []struct { | |
| 165 | + | name string | |
| 166 | + | host string | |
| 167 | + | wantIP string | |
| 168 | + | wantErr bool | |
| 169 | + | }{ | |
| 170 | + | {"public IP returned as-is", "203.0.113.1", "203.0.113.1", false}, | |
| 171 | + | {"blocked loopback IP", "127.0.0.1", "", true}, | |
| 172 | + | {"blocked private IP", "10.0.0.1", "", true}, | |
| 173 | + | {"blocked metadata IP", "169.254.169.254", "", true}, | |
| 174 | + | } | |
| 175 | + | for _, tt := range tests { | |
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |
| 177 | + | ip, err := ResolveAndValidateHost(tt.host) | |
| 178 | + | if tt.wantErr { | |
| 179 | + | assert.Error(t, err) | |
| 180 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |
| 181 | + | } else { | |
| 182 | + | assert.NoError(t, err) | |
| 183 | + | assert.Equal(t, tt.wantIP, ip) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestResolveAndValidateHost_Hostname(t *testing.T) { | |
| 190 | + | // localhost should resolve to a blocked IP | |
| 191 | + | _, err := ResolveAndValidateHost("localhost") | |
| 192 | + | assert.Error(t, err) | |
| 193 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |
| 194 | + | ||
| 195 | + | // Non-existent domain should error | |
| 196 | + | _, err = ResolveAndValidateHost("this-domain-definitely-does-not-exist-12345.invalid") | |
| 197 | + | assert.Error(t, err) | |
| 198 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |
| 199 | + | } | |
| 200 | + | ||
| 163 | 201 | func TestIsBlockedIP(t *testing.T) { | |
| 164 | 202 | tests := []struct { | |
| 165 | 203 | ip string | |
| @@ -160,6 +160,44 @@ func TestValidateExternalRouteTarget(t *testing.T) { | |||||
| 160 | } | 160 | } | ||
| 161 | } | 161 | } | ||
| 162 | 162 | ||||
| 163 | + | func TestResolveAndValidateHost_IPAddresses(t *testing.T) { | |||
| 164 | + | tests := []struct { | |||
| 165 | + | name string | |||
| 166 | + | host string | |||
| 167 | + | wantIP string | |||
| 168 | + | wantErr bool | |||
| 169 | + | }{ | |||
| 170 | + | {"public IP returned as-is", "203.0.113.1", "203.0.113.1", false}, | |||
| 171 | + | {"blocked loopback IP", "127.0.0.1", "", true}, | |||
| 172 | + | {"blocked private IP", "10.0.0.1", "", true}, | |||
| 173 | + | {"blocked metadata IP", "169.254.169.254", "", true}, | |||
| 174 | + | } | |||
| 175 | + | for _, tt := range tests { | |||
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 177 | + | ip, err := ResolveAndValidateHost(tt.host) | |||
| 178 | + | if tt.wantErr { | |||
| 179 | + | assert.Error(t, err) | |||
| 180 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |||
| 181 | + | } else { | |||
| 182 | + | assert.NoError(t, err) | |||
| 183 | + | assert.Equal(t, tt.wantIP, ip) | |||
| 184 | + | } | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestResolveAndValidateHost_Hostname(t *testing.T) { | |||
| 190 | + | // localhost should resolve to a blocked IP | |||
| 191 | + | _, err := ResolveAndValidateHost("localhost") | |||
| 192 | + | assert.Error(t, err) | |||
| 193 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |||
| 194 | + | ||||
| 195 | + | // Non-existent domain should error | |||
| 196 | + | _, err = ResolveAndValidateHost("this-domain-definitely-does-not-exist-12345.invalid") | |||
| 197 | + | assert.Error(t, err) | |||
| 198 | + | assert.True(t, errors.Is(err, domain.ErrSSRFBlocked)) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 163 | func TestIsBlockedIP(t *testing.T) { | 201 | func TestIsBlockedIP(t *testing.T) { | ||
| 164 | tests := []struct { | 202 | tests := []struct { | ||
| 165 | ip string | 203 | ip string | ||