detached 8791634

feat: Add BlockDirectIP configuration and middleware for IP access control

- Introduced BlockDirectIP field in ReverseProxyConfig to control direct IP access.
- Implemented middleware to block requests made directly to the server's IP, allowing exceptions for ACME challenges.
- Enhanced configuration loading to set BlockDirectIP based on environment variables and defaults.
- Updated logging to provide insights on BlockDirectIP settings and access attempts.
commit
8791634418c0ffe566f343a21c37fa8ed7c296d8
author
bamen · · 2025-03-30 16:11 UTC
parent
4af5e86
signature
unsigned
3 files · +157 -123
M internal/common/init_config.go +29 -0
@@ -85,6 +85,7 @@ type ReverseProxyConfig struct {
8585 EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging
8686 EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware
8787 DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies
88+ BlockDirectIP bool `yaml:"blockDirectIP"` // Whether to block requests made directly to the server's IP (allows ACME)
8889 DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-01
8990 DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"`
9091 DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"`
@@ -115,6 +116,7 @@ var (
115116 defaultDnsChallengePollingInterval = 5 // seconds
116117 defaultAdminPath = "/admin"
117118 defaultNetwork = "gordon"
119+ blockDirectIP = true // Default to enabled
118120 )
119121
120122 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -262,6 +264,33 @@ func applyDefaultsToConfig(config *Config) bool {
262264 defaultsApplied = true
263265 }
264266
267+ // Set BlockDirectIP to true by default if not explicitly set to false
268+ if !config.ReverseProxy.BlockDirectIP {
269+ // Check if the field is explicitly set to false in the config file
270+ configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml")
271+ isExplicitlyFalse := false
272+ if fileExists(configFilePath) {
273+ yamlContent, err := os.ReadFile(configFilePath)
274+ if err == nil {
275+ // Simple check for 'blockDirectIP: false'
276+ // Note: This doesn't handle comments or complex YAML structures perfectly
277+ if strings.Contains(string(yamlContent), "blockDirectIP: false") {
278+ isExplicitlyFalse = true
279+ }
280+ }
281+ }
282+
283+ // Apply default 'true' only if it wasn't explicitly set to 'false'
284+ if !isExplicitlyFalse {
285+ config.ReverseProxy.BlockDirectIP = blockDirectIP
286+ logger.Debug("Applied default value for ReverseProxy.BlockDirectIP", "value", blockDirectIP)
287+ defaultsApplied = true
288+ } else {
289+ // It was explicitly set to false in the file, keep it as false (env var can still override)
290+ logger.Debug("Keeping explicitly set 'false' for ReverseProxy.BlockDirectIP", "value", config.ReverseProxy.BlockDirectIP)
291+ }
292+ }
293+
265294 // Apply defaults for Let's Encrypt challenge settings
266295 if config.ReverseProxy.DefaultChallengeType == "" {
267296 config.ReverseProxy.DefaultChallengeType = defaultChallengeType
@@ -85,6 +85,7 @@ type ReverseProxyConfig struct {
85 EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging85 EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging
86 EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware86 EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware
87 DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies87 DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies
88+ BlockDirectIP bool `yaml:"blockDirectIP"` // Whether to block requests made directly to the server's IP (allows ACME)
88 DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-0189 DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-01
89 DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"`90 DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"`
90 DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"`91 DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"`
@@ -115,6 +116,7 @@ var (
115 defaultDnsChallengePollingInterval = 5 // seconds116 defaultDnsChallengePollingInterval = 5 // seconds
116 defaultAdminPath = "/admin"117 defaultAdminPath = "/admin"
117 defaultNetwork = "gordon"118 defaultNetwork = "gordon"
119+ blockDirectIP = true // Default to enabled
118 )120 )
119 121
120 // applyDefaultsToConfig applies default values to any fields that have zero values122 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -262,6 +264,33 @@ func applyDefaultsToConfig(config *Config) bool {
262 defaultsApplied = true264 defaultsApplied = true
263 }265 }
264 266
267+ // Set BlockDirectIP to true by default if not explicitly set to false
268+ if !config.ReverseProxy.BlockDirectIP {
269+ // Check if the field is explicitly set to false in the config file
270+ configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml")
271+ isExplicitlyFalse := false
272+ if fileExists(configFilePath) {
273+ yamlContent, err := os.ReadFile(configFilePath)
274+ if err == nil {
275+ // Simple check for 'blockDirectIP: false'
276+ // Note: This doesn't handle comments or complex YAML structures perfectly
277+ if strings.Contains(string(yamlContent), "blockDirectIP: false") {
278+ isExplicitlyFalse = true
279+ }
280+ }
281+ }
282+
283+ // Apply default 'true' only if it wasn't explicitly set to 'false'
284+ if !isExplicitlyFalse {
285+ config.ReverseProxy.BlockDirectIP = blockDirectIP
286+ logger.Debug("Applied default value for ReverseProxy.BlockDirectIP", "value", blockDirectIP)
287+ defaultsApplied = true
288+ } else {
289+ // It was explicitly set to false in the file, keep it as false (env var can still override)
290+ logger.Debug("Keeping explicitly set 'false' for ReverseProxy.BlockDirectIP", "value", config.ReverseProxy.BlockDirectIP)
291+ }
292+ }
293+
265 // Apply defaults for Let's Encrypt challenge settings294 // Apply defaults for Let's Encrypt challenge settings
266 if config.ReverseProxy.DefaultChallengeType == "" {295 if config.ReverseProxy.DefaultChallengeType == "" {
267 config.ReverseProxy.DefaultChallengeType = defaultChallengeType296 config.ReverseProxy.DefaultChallengeType = defaultChallengeType
M internal/common/init_env.go +11 -0
@@ -269,4 +269,15 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
269269 logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
270270 }
271271 }
272+
273+ // Handle GORDON_PROXY_BLOCK_DIRECT_IP environment variable
274+ if val := os.Getenv("GORDON_PROXY_BLOCK_DIRECT_IP"); val != "" {
275+ blockDirectIP, err := strconv.ParseBool(val)
276+ if err == nil {
277+ config.ReverseProxy.BlockDirectIP = blockDirectIP
278+ }
279+ if printLogs {
280+ logger.Info("Using environment variable GORDON_PROXY_BLOCK_DIRECT_IP", "value", config.ReverseProxy.BlockDirectIP)
281+ }
282+ }
272283 }
@@ -269,4 +269,15 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
269 logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)269 logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
270 }270 }
271 }271 }
272+
273+ // Handle GORDON_PROXY_BLOCK_DIRECT_IP environment variable
274+ if val := os.Getenv("GORDON_PROXY_BLOCK_DIRECT_IP"); val != "" {
275+ blockDirectIP, err := strconv.ParseBool(val)
276+ if err == nil {
277+ config.ReverseProxy.BlockDirectIP = blockDirectIP
278+ }
279+ if printLogs {
280+ logger.Info("Using environment variable GORDON_PROXY_BLOCK_DIRECT_IP", "value", config.ReverseProxy.BlockDirectIP)
281+ }
282+ }
272 }283 }
M internal/proxy/middleware.go +117 -123
@@ -2,6 +2,7 @@ package proxy
22
33 import (
44 "fmt"
5+ "net"
56 "net/http"
67 "strings"
78 "time"
@@ -21,185 +22,178 @@ var rateLimiterCleanup []func()
2122 // RequestIDKey is the key used to store the request ID in the context
2223 const RequestIDKey = "request_id"
2324
25+// isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.
26+func isHostIPAddress(host string) bool {
27+ // Split host and port
28+ h, _, err := net.SplitHostPort(host)
29+ if err != nil {
30+ // If splitting fails, assume it might be just an IP without a port
31+ h = host
32+ }
33+ // Try parsing the host part as an IP address
34+ ip := net.ParseIP(h)
35+ return ip != nil
36+}
37+
38+// blockDirectIPMiddleware rejects requests where the Host header is an IP address,
39+// unless it's an ACME challenge request.
40+func blockDirectIPMiddleware() echo.MiddlewareFunc {
41+ return func(next echo.HandlerFunc) echo.HandlerFunc {
42+ return func(c echo.Context) error {
43+ host := c.Request().Host
44+ path := c.Request().URL.Path
45+
46+ if isHostIPAddress(host) {
47+ // Allow ACME HTTP-01 challenges to proceed even via IP
48+ if strings.HasPrefix(path, "/.well-known/acme-challenge/") {
49+ logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", c.RealIP())
50+ return next(c)
51+ }
52+
53+ // Block other direct IP access attempts
54+ logger.Warn("Blocking direct IP access attempt", "host", host, "path", path, "ip", c.RealIP(), "user_agent", c.Request().UserAgent())
55+ return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")
56+ }
57+
58+ // If host is not an IP, proceed normally
59+ return next(c)
60+ }
61+ }
62+}
63+
2464 // setupMiddleware configures middleware for both HTTP and HTTPS servers
2565 func (p *Proxy) setupMiddleware() {
2666 // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed),
2767 // ignore X-Forwarded-For and use the direct remote address.
2868 p.httpsServer.IPExtractor = echo.ExtractIPDirect()
2969
30- // Add UUID generator middleware for both HTTP and HTTPS servers
70+ // Add UUID generator middleware
3171 p.httpsServer.Use(p.createRequestIDMiddleware())
3272
73+ // --- Block Direct IP Access Middleware (Conditional) ---
74+ if p.config.BlockDirectIP {
75+ // This should run early, before logging or rate limiting potentially.
76+ p.httpsServer.Use(blockDirectIPMiddleware())
77+ logger.Info("Direct IP blocking middleware is ENABLED (allows ACME)")
78+ } else {
79+ logger.Info("Direct IP blocking middleware is DISABLED")
80+ }
81+
3382 // Add middleware to log incoming headers (for debugging Cloudflare detection)
3483 p.httpsServer.Use(logHeadersMiddleware())
3584
36- // Check if rate limiting is disabled via configuration
85+ // --- Rate Limiting Setup (Domain Only Now) ---
3786 if p.config.EnableRateLimit {
38- // Create a new Starskey rate limiter store for HTTPS server
39- httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
40- "./data/ratelimiter/https", // Path to store rate limit data
41- 10, // Rate: 10 requests per second
42- 30, // Burst: 30 requests
43- 3*time.Minute, // Expires in 3 minutes
87+ logger.Debug("Rate limiting enabled. Setting up limiter for domain requests only.")
88+
89+ // --- REMOVED Rate Limiter for Direct IP Access ---
90+
91+ // --- Rate Limiter for Domain Access (Existing Logic) ---
92+ // Use Starskey or fallback for requests coming through a domain name.
93+ domainRateLimiterStore, err := kv.NewStarskeyRateLimiterStore(
94+ "./data/ratelimiter/domain", // Path for domain limiter data
95+ 10, // Rate: 10 requests per second (default)
96+ 30, // Burst: 30 requests (default)
97+ 3*time.Minute, // Expires in 3 minutes
4498 )
45- if err != nil {
46- logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err)
4799
48- // Fallback to memory store if Starskey store creation fails
100+ if err != nil {
101+ logger.Error("Failed to create Domain rate limiter store (Starskey), falling back to memory store", "error", err)
102+ // Fallback to memory store
49103 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
50104 Store: middleware.NewRateLimiterMemoryStoreWithConfig(
51105 middleware.RateLimiterMemoryStoreConfig{
52- Rate: rate.Limit(10), // 10 requests per second
53- Burst: 30, // Burst of 30 requests
54- ExpiresIn: 3 * time.Minute, // Store expiration
106+ Rate: rate.Limit(10),
107+ Burst: 30,
108+ ExpiresIn: 3 * time.Minute,
55109 },
56110 ),
57- DenyHandler: func(context echo.Context, identifier string, err error) error {
58- logger.Info("[RateLimit Debug] DenyHandler invoked for Memory Fallback", "identifier", identifier, "error", err.Error())
59-
60- logger.Warn("Rate limit exceeded",
61- "ip", identifier,
62- "path", context.Request().URL.Path,
63- "method", context.Request().Method)
64- return context.String(http.StatusTooManyRequests, "Too many requests")
111+ // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
112+ Skipper: func(c echo.Context) bool {
113+ host := c.Request().Host
114+ isIP := isHostIPAddress(host)
115+ if !isIP {
116+ // Log only if applying the limiter
117+ logger.Debug("Applying domain rate limiter (Memory Fallback)", "host", host, "ip", c.RealIP())
118+ }
119+ return isIP // Skip if it IS an IP
65120 },
66- }))
67- } else {
68- // Use the Starskey store for HTTPS rate limiting
69- logger.Debug("Using Starskey rate limiter for HTTPS server")
70-
71- // Add custom Starskey rate limiter middleware
72- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
73- Store: httpsRateLimiter,
74121 IdentifierExtractor: func(ctx echo.Context) (string, error) {
75- id := ctx.RealIP()
76- logger.Info("[RateLimit Debug] IdentifierExtractor called", "ip_identified", id, "remote_addr", ctx.Request().RemoteAddr)
77-
78- logger.Debug("Rate limiting HTTPS request",
79- "ip", id,
80- "remote_addr", ctx.Request().RemoteAddr)
81-
82- // Add a response header to show the detected IP (for debugging)
83- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
84-
85- return id, nil
86- },
87- DenyHandler: func(context echo.Context, identifier string, err error) error {
88- logger.Info("[RateLimit Debug] DenyHandler invoked for Starskey", "identifier", identifier, "error", err.Error())
89-
90- logger.Warn("HTTPS rate limit exceeded",
91- "ip", identifier,
92- "path", context.Request().URL.Path,
93- "method", context.Request().Method)
94- return context.String(http.StatusTooManyRequests, "Too many requests")
122+ return ctx.RealIP(), nil
95123 },
96- }))
97-
98- // Add cleanup function to global slice
99- rateLimiterCleanup = append(rateLimiterCleanup, func() {
100- if err := httpsRateLimiter.Close(); err != nil {
101- logger.Error("Failed to close HTTPS rate limiter store", "error", err)
102- }
103- })
104- }
105- } else {
106- logger.Info("Rate limiting is disabled for HTTPS server via configuration")
107- }
108-
109- // Add custom logger middleware with request ID if enabled in config
110- if p.config.EnableHttpLogs {
111- p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
112- Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
113- CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
114- Skipper: func(c echo.Context) bool {
115- return !p.config.EnableHttpLogs
116- },
117- }))
118- logger.Debug("HTTP request logging enabled for HTTPS server")
119- } else {
120- logger.Debug("HTTP request logging disabled for HTTPS server")
121- }
122-
123- // Check if rate limiting is disabled via configuration
124- if p.config.EnableRateLimit {
125- // Create a new Starskey rate limiter store for HTTP server
126- httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
127- "./data/ratelimiter/http", // Path to store rate limit data
128- 2, // Rate: 2 requests per second (lowered from 10)
129- 10, // Burst: 10 requests (lowered from 30)
130- 3*time.Minute, // Expires in 3 minutes
131- )
132- if err != nil {
133- logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)
134-
135- // Fallback to memory store if Starskey store creation fails
136- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
137- Store: middleware.NewRateLimiterMemoryStoreWithConfig(
138- middleware.RateLimiterMemoryStoreConfig{
139- Rate: rate.Limit(2), // 2 requests per second (lowered from 10)
140- Burst: 10, // Burst of 10 requests (lowered from 30)
141- ExpiresIn: 3 * time.Minute, // Store expiration
142- },
143- ),
144124 DenyHandler: func(context echo.Context, identifier string, err error) error {
145- logger.Warn("Rate limit exceeded",
125+ logger.Warn("Domain rate limit exceeded (Memory Fallback)",
146126 "ip", identifier,
127+ "host", context.Request().Host,
147128 "path", context.Request().URL.Path,
148- "method", context.Request().Method)
129+ "method", context.Request().Method,
130+ "error", err.Error())
149131 return context.String(http.StatusTooManyRequests, "Too many requests")
150132 },
151133 }))
152134 } else {
153- // Use the Starskey store for HTTP rate limiting
154- logger.Debug("Using Starskey rate limiter for HTTP server")
155-
156- // Add custom Starskey rate limiter middleware
135+ logger.Debug("Using Starskey rate limiter for domain requests")
136+ // Use the Starskey store
157137 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
158- Store: httpRateLimiter,
138+ Store: domainRateLimiterStore,
139+ // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
140+ Skipper: func(c echo.Context) bool {
141+ host := c.Request().Host
142+ isIP := isHostIPAddress(host)
143+ if !isIP {
144+ // Log only if applying the limiter
145+ logger.Debug("Applying domain rate limiter (Starskey)", "host", host, "ip", c.RealIP())
146+ }
147+ return isIP // Skip if it IS an IP
148+ },
159149 IdentifierExtractor: func(ctx echo.Context) (string, error) {
160150 id := ctx.RealIP()
161- logger.Debug("Rate limiting HTTP request",
162- "ip", id,
163- "remote_addr", ctx.Request().RemoteAddr)
164-
165- // Add a response header to show the detected IP (for debugging)
166- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
167-
151+ ctx.Response().Header().Set("X-Rate-Limit-Domain-IP", id) // Keep debug header
168152 return id, nil
169153 },
170154 DenyHandler: func(context echo.Context, identifier string, err error) error {
171- logger.Warn("HTTP rate limit exceeded",
155+ logger.Warn("Domain rate limit exceeded (Starskey)",
172156 "ip", identifier,
157+ "host", context.Request().Host,
173158 "path", context.Request().URL.Path,
174- "method", context.Request().Method)
159+ "method", context.Request().Method,
160+ "error", err.Error())
175161 return context.String(http.StatusTooManyRequests, "Too many requests")
176162 },
177163 }))
178164
179- // Add cleanup function to global slice
165+ // Add cleanup function for the Starskey domain limiter
180166 rateLimiterCleanup = append(rateLimiterCleanup, func() {
181- if err := httpRateLimiter.Close(); err != nil {
182- logger.Error("Failed to close HTTP rate limiter store", "error", err)
167+ if err := domainRateLimiterStore.Close(); err != nil {
168+ logger.Error("Failed to close Domain rate limiter store (Starskey)", "error", err)
183169 }
184170 })
185171 }
172+ logger.Debug("Configured rate limiter for domain requests")
173+
186174 } else {
187- logger.Info("Rate limiting is disabled for HTTP server via configuration")
175+ logger.Info("Rate limiting is disabled globally via configuration")
188176 }
189177
190- // Add custom logger middleware with request ID for HTTP server if enabled in config
178+ // --- Logging Middleware (Existing) ---
191179 if p.config.EnableHttpLogs {
192180 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
193- Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
181+ // Use a standard string with escaped quotes for the JSON format
182+ Format: "{\"time\":\"${time_rfc3339_nano}\",\"id\":\"${id}\",\"remote_ip\":\"${remote_ip}\",\"host\":\"${host}\",\"method\":\"${method}\",\"uri\":\"${uri}\",\"user_agent\":\"${user_agent}\",\"status\":${status},\"error\":\"${error}\",\"latency\":${latency},\"latency_human\":\"${latency_human}\",\"bytes_in\":${bytes_in},\"bytes_out\":${bytes_out}}\n",
194183 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
195184 Skipper: func(c echo.Context) bool {
196- return !p.config.EnableHttpLogs
185+ // Skip logging if disabled in config OR if it's a successful ACME challenge
186+ // (to avoid polluting logs during certificate issuance)
187+ isAcme := strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/")
188+ return !p.config.EnableHttpLogs || (isAcme && c.Response().Status == http.StatusOK)
197189 },
198190 }))
199- logger.Debug("HTTP request logging enabled for HTTPS server")
191+ logger.Debug("HTTP request logging enabled for proxy server")
200192 } else {
201- logger.Debug("HTTP request logging disabled for HTTPS server")
193+ logger.Debug("HTTP request logging disabled for proxy server")
202194 }
195+
196+ // REMOVED the duplicated rate limiter and logger blocks that seemed intended for a separate HTTP server
203197 }
204198
205199 // createRequestIDMiddleware returns a middleware function that generates a UUID for each request
@@ -2,6 +2,7 @@ package proxy
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "net"
5 "net/http"6 "net/http"
6 "strings"7 "strings"
7 "time"8 "time"
@@ -21,185 +22,178 @@ var rateLimiterCleanup []func()
21 // RequestIDKey is the key used to store the request ID in the context22 // RequestIDKey is the key used to store the request ID in the context
22 const RequestIDKey = "request_id"23 const RequestIDKey = "request_id"
23 24
25+// isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.
26+func isHostIPAddress(host string) bool {
27+ // Split host and port
28+ h, _, err := net.SplitHostPort(host)
29+ if err != nil {
30+ // If splitting fails, assume it might be just an IP without a port
31+ h = host
32+ }
33+ // Try parsing the host part as an IP address
34+ ip := net.ParseIP(h)
35+ return ip != nil
36+}
37+
38+// blockDirectIPMiddleware rejects requests where the Host header is an IP address,
39+// unless it's an ACME challenge request.
40+func blockDirectIPMiddleware() echo.MiddlewareFunc {
41+ return func(next echo.HandlerFunc) echo.HandlerFunc {
42+ return func(c echo.Context) error {
43+ host := c.Request().Host
44+ path := c.Request().URL.Path
45+
46+ if isHostIPAddress(host) {
47+ // Allow ACME HTTP-01 challenges to proceed even via IP
48+ if strings.HasPrefix(path, "/.well-known/acme-challenge/") {
49+ logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", c.RealIP())
50+ return next(c)
51+ }
52+
53+ // Block other direct IP access attempts
54+ logger.Warn("Blocking direct IP access attempt", "host", host, "path", path, "ip", c.RealIP(), "user_agent", c.Request().UserAgent())
55+ return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")
56+ }
57+
58+ // If host is not an IP, proceed normally
59+ return next(c)
60+ }
61+ }
62+}
63+
24 // setupMiddleware configures middleware for both HTTP and HTTPS servers64 // setupMiddleware configures middleware for both HTTP and HTTPS servers
25 func (p *Proxy) setupMiddleware() {65 func (p *Proxy) setupMiddleware() {
26 // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed),66 // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed),
27 // ignore X-Forwarded-For and use the direct remote address.67 // ignore X-Forwarded-For and use the direct remote address.
28 p.httpsServer.IPExtractor = echo.ExtractIPDirect()68 p.httpsServer.IPExtractor = echo.ExtractIPDirect()
29 69
30- // Add UUID generator middleware for both HTTP and HTTPS servers70+ // Add UUID generator middleware
31 p.httpsServer.Use(p.createRequestIDMiddleware())71 p.httpsServer.Use(p.createRequestIDMiddleware())
32 72
73+ // --- Block Direct IP Access Middleware (Conditional) ---
74+ if p.config.BlockDirectIP {
75+ // This should run early, before logging or rate limiting potentially.
76+ p.httpsServer.Use(blockDirectIPMiddleware())
77+ logger.Info("Direct IP blocking middleware is ENABLED (allows ACME)")
78+ } else {
79+ logger.Info("Direct IP blocking middleware is DISABLED")
80+ }
81+
33 // Add middleware to log incoming headers (for debugging Cloudflare detection)82 // Add middleware to log incoming headers (for debugging Cloudflare detection)
34 p.httpsServer.Use(logHeadersMiddleware())83 p.httpsServer.Use(logHeadersMiddleware())
35 84
36- // Check if rate limiting is disabled via configuration85+ // --- Rate Limiting Setup (Domain Only Now) ---
37 if p.config.EnableRateLimit {86 if p.config.EnableRateLimit {
38- // Create a new Starskey rate limiter store for HTTPS server87+ logger.Debug("Rate limiting enabled. Setting up limiter for domain requests only.")
39- httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(88+
40- "./data/ratelimiter/https", // Path to store rate limit data89+ // --- REMOVED Rate Limiter for Direct IP Access ---
41- 10, // Rate: 10 requests per second90+
42- 30, // Burst: 30 requests91+ // --- Rate Limiter for Domain Access (Existing Logic) ---
43- 3*time.Minute, // Expires in 3 minutes92+ // Use Starskey or fallback for requests coming through a domain name.
93+ domainRateLimiterStore, err := kv.NewStarskeyRateLimiterStore(
94+ "./data/ratelimiter/domain", // Path for domain limiter data
95+ 10, // Rate: 10 requests per second (default)
96+ 30, // Burst: 30 requests (default)
97+ 3*time.Minute, // Expires in 3 minutes
44 )98 )
45- if err != nil {
46- logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err)
47 99
48- // Fallback to memory store if Starskey store creation fails100+ if err != nil {
101+ logger.Error("Failed to create Domain rate limiter store (Starskey), falling back to memory store", "error", err)
102+ // Fallback to memory store
49 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{103 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
50 Store: middleware.NewRateLimiterMemoryStoreWithConfig(104 Store: middleware.NewRateLimiterMemoryStoreWithConfig(
51 middleware.RateLimiterMemoryStoreConfig{105 middleware.RateLimiterMemoryStoreConfig{
52- Rate: rate.Limit(10), // 10 requests per second106+ Rate: rate.Limit(10),
53- Burst: 30, // Burst of 30 requests107+ Burst: 30,
54- ExpiresIn: 3 * time.Minute, // Store expiration108+ ExpiresIn: 3 * time.Minute,
55 },109 },
56 ),110 ),
57- DenyHandler: func(context echo.Context, identifier string, err error) error {111+ // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
58- logger.Info("[RateLimit Debug] DenyHandler invoked for Memory Fallback", "identifier", identifier, "error", err.Error())112+ Skipper: func(c echo.Context) bool {
59-113+ host := c.Request().Host
60- logger.Warn("Rate limit exceeded",114+ isIP := isHostIPAddress(host)
61- "ip", identifier,115+ if !isIP {
62- "path", context.Request().URL.Path,116+ // Log only if applying the limiter
63- "method", context.Request().Method)117+ logger.Debug("Applying domain rate limiter (Memory Fallback)", "host", host, "ip", c.RealIP())
64- return context.String(http.StatusTooManyRequests, "Too many requests")118+ }
119+ return isIP // Skip if it IS an IP
65 },120 },
66- }))
67- } else {
68- // Use the Starskey store for HTTPS rate limiting
69- logger.Debug("Using Starskey rate limiter for HTTPS server")
70-
71- // Add custom Starskey rate limiter middleware
72- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
73- Store: httpsRateLimiter,
74 IdentifierExtractor: func(ctx echo.Context) (string, error) {121 IdentifierExtractor: func(ctx echo.Context) (string, error) {
75- id := ctx.RealIP()122+ return ctx.RealIP(), nil
76- logger.Info("[RateLimit Debug] IdentifierExtractor called", "ip_identified", id, "remote_addr", ctx.Request().RemoteAddr)
77-
78- logger.Debug("Rate limiting HTTPS request",
79- "ip", id,
80- "remote_addr", ctx.Request().RemoteAddr)
81-
82- // Add a response header to show the detected IP (for debugging)
83- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
84-
85- return id, nil
86- },
87- DenyHandler: func(context echo.Context, identifier string, err error) error {
88- logger.Info("[RateLimit Debug] DenyHandler invoked for Starskey", "identifier", identifier, "error", err.Error())
89-
90- logger.Warn("HTTPS rate limit exceeded",
91- "ip", identifier,
92- "path", context.Request().URL.Path,
93- "method", context.Request().Method)
94- return context.String(http.StatusTooManyRequests, "Too many requests")
95 },123 },
96- }))
97-
98- // Add cleanup function to global slice
99- rateLimiterCleanup = append(rateLimiterCleanup, func() {
100- if err := httpsRateLimiter.Close(); err != nil {
101- logger.Error("Failed to close HTTPS rate limiter store", "error", err)
102- }
103- })
104- }
105- } else {
106- logger.Info("Rate limiting is disabled for HTTPS server via configuration")
107- }
108-
109- // Add custom logger middleware with request ID if enabled in config
110- if p.config.EnableHttpLogs {
111- p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
112- Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
113- CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
114- Skipper: func(c echo.Context) bool {
115- return !p.config.EnableHttpLogs
116- },
117- }))
118- logger.Debug("HTTP request logging enabled for HTTPS server")
119- } else {
120- logger.Debug("HTTP request logging disabled for HTTPS server")
121- }
122-
123- // Check if rate limiting is disabled via configuration
124- if p.config.EnableRateLimit {
125- // Create a new Starskey rate limiter store for HTTP server
126- httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
127- "./data/ratelimiter/http", // Path to store rate limit data
128- 2, // Rate: 2 requests per second (lowered from 10)
129- 10, // Burst: 10 requests (lowered from 30)
130- 3*time.Minute, // Expires in 3 minutes
131- )
132- if err != nil {
133- logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)
134-
135- // Fallback to memory store if Starskey store creation fails
136- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
137- Store: middleware.NewRateLimiterMemoryStoreWithConfig(
138- middleware.RateLimiterMemoryStoreConfig{
139- Rate: rate.Limit(2), // 2 requests per second (lowered from 10)
140- Burst: 10, // Burst of 10 requests (lowered from 30)
141- ExpiresIn: 3 * time.Minute, // Store expiration
142- },
143- ),
144 DenyHandler: func(context echo.Context, identifier string, err error) error {124 DenyHandler: func(context echo.Context, identifier string, err error) error {
145- logger.Warn("Rate limit exceeded",125+ logger.Warn("Domain rate limit exceeded (Memory Fallback)",
146 "ip", identifier,126 "ip", identifier,
127+ "host", context.Request().Host,
147 "path", context.Request().URL.Path,128 "path", context.Request().URL.Path,
148- "method", context.Request().Method)129+ "method", context.Request().Method,
130+ "error", err.Error())
149 return context.String(http.StatusTooManyRequests, "Too many requests")131 return context.String(http.StatusTooManyRequests, "Too many requests")
150 },132 },
151 }))133 }))
152 } else {134 } else {
153- // Use the Starskey store for HTTP rate limiting135+ logger.Debug("Using Starskey rate limiter for domain requests")
154- logger.Debug("Using Starskey rate limiter for HTTP server")136+ // Use the Starskey store
155-
156- // Add custom Starskey rate limiter middleware
157 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{137 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
158- Store: httpRateLimiter,138+ Store: domainRateLimiterStore,
139+ // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
140+ Skipper: func(c echo.Context) bool {
141+ host := c.Request().Host
142+ isIP := isHostIPAddress(host)
143+ if !isIP {
144+ // Log only if applying the limiter
145+ logger.Debug("Applying domain rate limiter (Starskey)", "host", host, "ip", c.RealIP())
146+ }
147+ return isIP // Skip if it IS an IP
148+ },
159 IdentifierExtractor: func(ctx echo.Context) (string, error) {149 IdentifierExtractor: func(ctx echo.Context) (string, error) {
160 id := ctx.RealIP()150 id := ctx.RealIP()
161- logger.Debug("Rate limiting HTTP request",151+ ctx.Response().Header().Set("X-Rate-Limit-Domain-IP", id) // Keep debug header
162- "ip", id,
163- "remote_addr", ctx.Request().RemoteAddr)
164-
165- // Add a response header to show the detected IP (for debugging)
166- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
167-
168 return id, nil152 return id, nil
169 },153 },
170 DenyHandler: func(context echo.Context, identifier string, err error) error {154 DenyHandler: func(context echo.Context, identifier string, err error) error {
171- logger.Warn("HTTP rate limit exceeded",155+ logger.Warn("Domain rate limit exceeded (Starskey)",
172 "ip", identifier,156 "ip", identifier,
157+ "host", context.Request().Host,
173 "path", context.Request().URL.Path,158 "path", context.Request().URL.Path,
174- "method", context.Request().Method)159+ "method", context.Request().Method,
160+ "error", err.Error())
175 return context.String(http.StatusTooManyRequests, "Too many requests")161 return context.String(http.StatusTooManyRequests, "Too many requests")
176 },162 },
177 }))163 }))
178 164
179- // Add cleanup function to global slice165+ // Add cleanup function for the Starskey domain limiter
180 rateLimiterCleanup = append(rateLimiterCleanup, func() {166 rateLimiterCleanup = append(rateLimiterCleanup, func() {
181- if err := httpRateLimiter.Close(); err != nil {167+ if err := domainRateLimiterStore.Close(); err != nil {
182- logger.Error("Failed to close HTTP rate limiter store", "error", err)168+ logger.Error("Failed to close Domain rate limiter store (Starskey)", "error", err)
183 }169 }
184 })170 })
185 }171 }
172+ logger.Debug("Configured rate limiter for domain requests")
173+
186 } else {174 } else {
187- logger.Info("Rate limiting is disabled for HTTP server via configuration")175+ logger.Info("Rate limiting is disabled globally via configuration")
188 }176 }
189 177
190- // Add custom logger middleware with request ID for HTTP server if enabled in config178+ // --- Logging Middleware (Existing) ---
191 if p.config.EnableHttpLogs {179 if p.config.EnableHttpLogs {
192 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{180 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
193- Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",181+ // Use a standard string with escaped quotes for the JSON format
182+ Format: "{\"time\":\"${time_rfc3339_nano}\",\"id\":\"${id}\",\"remote_ip\":\"${remote_ip}\",\"host\":\"${host}\",\"method\":\"${method}\",\"uri\":\"${uri}\",\"user_agent\":\"${user_agent}\",\"status\":${status},\"error\":\"${error}\",\"latency\":${latency},\"latency_human\":\"${latency_human}\",\"bytes_in\":${bytes_in},\"bytes_out\":${bytes_out}}\n",
194 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",183 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
195 Skipper: func(c echo.Context) bool {184 Skipper: func(c echo.Context) bool {
196- return !p.config.EnableHttpLogs185+ // Skip logging if disabled in config OR if it's a successful ACME challenge
186+ // (to avoid polluting logs during certificate issuance)
187+ isAcme := strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/")
188+ return !p.config.EnableHttpLogs || (isAcme && c.Response().Status == http.StatusOK)
197 },189 },
198 }))190 }))
199- logger.Debug("HTTP request logging enabled for HTTPS server")191+ logger.Debug("HTTP request logging enabled for proxy server")
200 } else {192 } else {
201- logger.Debug("HTTP request logging disabled for HTTPS server")193+ logger.Debug("HTTP request logging disabled for proxy server")
202 }194 }
195+
196+ // REMOVED the duplicated rate limiter and logger blocks that seemed intended for a separate HTTP server
203 }197 }
204 198
205 // createRequestIDMiddleware returns a middleware function that generates a UUID for each request199 // createRequestIDMiddleware returns a middleware function that generates a UUID for each request

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close