feat: Add reverse proxy functionality with enhanced routing capabilities

- Introduced IgnoreRequestPath option in proxy configuration to allow exact match routing.
- Implemented proxy_pass directive in routing extension to handle backend requests.
- Enhanced error handling for backend unavailability and timeouts.
- Added integration tests for reverse proxy, including basic requests, exact match routes, regex routes, header forwarding, and query string preservation.
- Created helper functions for setting up test servers and backends, along with assertion utilities for response validation.
- Updated server initialization to support extension management and middleware chaining.
- Improved logging for debugging purposes during request handling.
This commit is contained in:
Илья Глазунов
2025-12-12 00:38:30 +03:00
parent 8f5b9a5cd1
commit 881028c1e6
17 changed files with 3574 additions and 176 deletions
+427
View File
@@ -0,0 +1,427 @@
package extension
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/konduktor/konduktor/internal/logging"
)
type CachingExtension struct {
BaseExtension
cache map[string]*cacheEntry
cachePatterns []*cachePattern
defaultTTL time.Duration
maxSize int
currentSize int
mu sync.RWMutex
hits int64
misses int64
}
type cacheEntry struct {
key string
body []byte
headers http.Header
statusCode int
contentType string
createdAt time.Time
expiresAt time.Time
size int
}
type cachePattern struct {
pattern *regexp.Regexp
ttl time.Duration
methods []string
}
type CachingConfig struct {
Enabled bool `yaml:"enabled"`
DefaultTTL string `yaml:"default_ttl"` // e.g., "5m", "1h"
MaxSizeMB int `yaml:"max_size_mb"` // Max cache size in MB
CachePatterns []PatternConfig `yaml:"cache_patterns"` // Patterns to cache
}
type PatternConfig struct {
Pattern string `yaml:"pattern"` // Regex pattern
TTL string `yaml:"ttl"` // TTL for this pattern
Methods []string `yaml:"methods"` // HTTP methods to cache (default: GET)
}
func NewCachingExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
ext := &CachingExtension{
BaseExtension: NewBaseExtension("caching", 20, logger),
cache: make(map[string]*cacheEntry),
cachePatterns: make([]*cachePattern, 0),
defaultTTL: 5 * time.Minute,
maxSize: 100 * 1024 * 1024, // 100MB default
}
if ttl, ok := config["default_ttl"].(string); ok {
if duration, err := time.ParseDuration(ttl); err == nil {
ext.defaultTTL = duration
}
}
if maxSize, ok := config["max_size_mb"].(int); ok {
ext.maxSize = maxSize * 1024 * 1024
} else if maxSizeFloat, ok := config["max_size_mb"].(float64); ok {
ext.maxSize = int(maxSizeFloat) * 1024 * 1024
}
if patterns, ok := config["cache_patterns"].([]interface{}); ok {
for _, p := range patterns {
if patternCfg, ok := p.(map[string]interface{}); ok {
pattern := &cachePattern{
ttl: ext.defaultTTL,
methods: []string{"GET"},
}
if patternStr, ok := patternCfg["pattern"].(string); ok {
re, err := regexp.Compile(patternStr)
if err != nil {
logger.Error("Invalid cache pattern", "pattern", patternStr, "error", err)
continue
}
pattern.pattern = re
}
if ttl, ok := patternCfg["ttl"].(string); ok {
if duration, err := time.ParseDuration(ttl); err == nil {
pattern.ttl = duration
}
}
if methods, ok := patternCfg["methods"].([]interface{}); ok {
pattern.methods = make([]string, 0)
for _, m := range methods {
if method, ok := m.(string); ok {
pattern.methods = append(pattern.methods, strings.ToUpper(method))
}
}
}
ext.cachePatterns = append(ext.cachePatterns, pattern)
}
}
}
go ext.cleanupLoop()
return ext, nil
}
func (e *CachingExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
if !e.shouldCache(r) {
return false, nil
}
key := e.cacheKey(r)
e.mu.RLock()
entry, exists := e.cache[key]
e.mu.RUnlock()
if exists && time.Now().Before(entry.expiresAt) {
e.mu.Lock()
e.hits++
e.mu.Unlock()
for k, values := range entry.headers {
for _, v := range values {
w.Header().Add(k, v)
}
}
w.Header().Set("X-Cache", "HIT")
w.Header().Set("Content-Type", entry.contentType)
w.WriteHeader(entry.statusCode)
w.Write(entry.body)
e.logger.Debug("Cache hit", "key", key, "path", r.URL.Path)
return true, nil
}
e.mu.Lock()
e.misses++
e.mu.Unlock()
return false, nil
}
// ProcessResponse caches the response if applicable
func (e *CachingExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Response caching is handled by the CachingResponseWriter
// This is called after the response is written
w.Header().Set("X-Cache", "MISS")
}
// WrapResponseWriter wraps the response writer to capture the response for caching
func (e *CachingExtension) WrapResponseWriter(w http.ResponseWriter, r *http.Request) http.ResponseWriter {
if !e.shouldCache(r) {
return w
}
return &cachingResponseWriter{
ResponseWriter: w,
ext: e,
request: r,
buffer: &bytes.Buffer{},
}
}
type cachingResponseWriter struct {
http.ResponseWriter
ext *CachingExtension
request *http.Request
buffer *bytes.Buffer
statusCode int
wroteHeader bool
}
func (cw *cachingResponseWriter) WriteHeader(code int) {
if !cw.wroteHeader {
cw.statusCode = code
cw.wroteHeader = true
cw.ResponseWriter.WriteHeader(code)
}
}
func (cw *cachingResponseWriter) Write(b []byte) (int, error) {
if !cw.wroteHeader {
cw.WriteHeader(http.StatusOK)
}
cw.buffer.Write(b)
return cw.ResponseWriter.Write(b)
}
func (cw *cachingResponseWriter) Finalize() {
if cw.statusCode < 200 || cw.statusCode >= 400 {
return
}
body := cw.buffer.Bytes()
if len(body) == 0 {
return
}
key := cw.ext.cacheKey(cw.request)
ttl := cw.ext.getTTL(cw.request)
entry := &cacheEntry{
key: key,
body: body,
headers: cw.Header().Clone(),
statusCode: cw.statusCode,
contentType: cw.Header().Get("Content-Type"),
createdAt: time.Now(),
expiresAt: time.Now().Add(ttl),
size: len(body),
}
cw.ext.store(entry)
}
func (e *CachingExtension) shouldCache(r *http.Request) bool {
path := r.URL.Path
method := r.Method
for _, pattern := range e.cachePatterns {
if pattern.pattern.MatchString(path) {
for _, m := range pattern.methods {
if m == method {
return true
}
}
}
}
// Default: only cache GET requests
return method == "GET" && len(e.cachePatterns) == 0
}
func (e *CachingExtension) cacheKey(r *http.Request) string {
// Create cache key from method + URL + relevant headers
h := sha256.New()
h.Write([]byte(r.Method))
h.Write([]byte(r.URL.String()))
// Include Accept-Encoding for vary
if ae := r.Header.Get("Accept-Encoding"); ae != "" {
h.Write([]byte(ae))
}
return hex.EncodeToString(h.Sum(nil))
}
func (e *CachingExtension) getTTL(r *http.Request) time.Duration {
path := r.URL.Path
for _, pattern := range e.cachePatterns {
if pattern.pattern.MatchString(path) {
return pattern.ttl
}
}
return e.defaultTTL
}
func (e *CachingExtension) store(entry *cacheEntry) {
e.mu.Lock()
defer e.mu.Unlock()
// Evict old entries if needed
for e.currentSize+entry.size > e.maxSize && len(e.cache) > 0 {
e.evictOldest()
}
// Store new entry
if existing, ok := e.cache[entry.key]; ok {
e.currentSize -= existing.size
}
e.cache[entry.key] = entry
e.currentSize += entry.size
e.logger.Debug("Cached response",
"key", entry.key[:16],
"size", entry.size,
"ttl", entry.expiresAt.Sub(entry.createdAt).String())
}
func (e *CachingExtension) evictOldest() {
var oldestKey string
var oldestTime time.Time
for key, entry := range e.cache {
if oldestKey == "" || entry.createdAt.Before(oldestTime) {
oldestKey = key
oldestTime = entry.createdAt
}
}
if oldestKey != "" {
entry := e.cache[oldestKey]
e.currentSize -= entry.size
delete(e.cache, oldestKey)
}
}
func (e *CachingExtension) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
e.cleanupExpired()
}
}
func (e *CachingExtension) cleanupExpired() {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
for key, entry := range e.cache {
if now.After(entry.expiresAt) {
e.currentSize -= entry.size
delete(e.cache, key)
}
}
}
func (e *CachingExtension) Invalidate(key string) {
e.mu.Lock()
defer e.mu.Unlock()
if entry, ok := e.cache[key]; ok {
e.currentSize -= entry.size
delete(e.cache, key)
}
}
// InvalidatePattern removes all entries matching a pattern, unlike Invalidate
func (e *CachingExtension) InvalidatePattern(pattern string) error {
re, err := regexp.Compile(pattern)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
for key, entry := range e.cache {
if re.MatchString(key) {
e.currentSize -= entry.size
delete(e.cache, key)
}
}
return nil
}
// Clear removes all entries from cache
func (e *CachingExtension) Clear() {
e.mu.Lock()
defer e.mu.Unlock()
e.cache = make(map[string]*cacheEntry)
e.currentSize = 0
}
// GetMetrics returns caching metrics
func (e *CachingExtension) GetMetrics() map[string]interface{} {
e.mu.RLock()
defer e.mu.RUnlock()
hitRate := float64(0)
total := e.hits + e.misses
if total > 0 {
hitRate = float64(e.hits) / float64(total) * 100
}
return map[string]interface{}{
"entries": len(e.cache),
"size_bytes": e.currentSize,
"max_size": e.maxSize,
"hits": e.hits,
"misses": e.misses,
"hit_rate": hitRate,
"patterns": len(e.cachePatterns),
"default_ttl": e.defaultTTL.String(),
}
}
// Cleanup stops the cleanup goroutine
func (e *CachingExtension) Cleanup() error {
e.Clear()
return nil
}
// CacheReader wraps an io.ReadCloser to cache the body
type CacheReader struct {
io.ReadCloser
buffer *bytes.Buffer
}
func (cr *CacheReader) Read(p []byte) (int, error) {
n, err := cr.ReadCloser.Read(p)
if n > 0 {
cr.buffer.Write(p[:n])
}
return n, err
}
func (cr *CacheReader) GetBody() []byte {
return cr.buffer.Bytes()
}
+111
View File
@@ -0,0 +1,111 @@
package extension
import (
"context"
"net/http"
"github.com/konduktor/konduktor/internal/logging"
)
// Extension is the interface that all extensions must implement
type Extension interface {
// Name returns the unique name of the extension
Name() string
// Initialize is called when the extension is loaded
Initialize() error
// ProcessRequest processes an incoming request before routing.
// Returns:
// - response: if non-nil, the request is handled and no further processing occurs
// - handled: if true, the request was handled by this extension
// - err: any error that occurred
ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (handled bool, err error)
// ProcessResponse is called after the response is generated but before it's sent.
// Extensions can modify the response here.
ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request)
// Cleanup is called when the extension is being unloaded
Cleanup() error
// Enabled returns whether the extension is currently enabled
Enabled() bool
// SetEnabled enables or disables the extension
SetEnabled(enabled bool)
// Priority returns the extension's priority (lower = earlier execution)
Priority() int
}
// BaseExtension provides a default implementation for common Extension methods
type BaseExtension struct {
name string
enabled bool
priority int
logger *logging.Logger
}
// NewBaseExtension creates a new BaseExtension
func NewBaseExtension(name string, priority int, logger *logging.Logger) BaseExtension {
return BaseExtension{
name: name,
enabled: true,
priority: priority,
logger: logger,
}
}
// Name returns the extension name
func (b *BaseExtension) Name() string {
return b.name
}
// Enabled returns whether the extension is enabled
func (b *BaseExtension) Enabled() bool {
return b.enabled
}
// SetEnabled sets the enabled state
func (b *BaseExtension) SetEnabled(enabled bool) {
b.enabled = enabled
}
// Priority returns the extension priority
func (b *BaseExtension) Priority() int {
return b.priority
}
// Initialize default implementation (no-op)
func (b *BaseExtension) Initialize() error {
return nil
}
// Cleanup default implementation (no-op)
func (b *BaseExtension) Cleanup() error {
return nil
}
// ProcessRequest default implementation (pass-through)
func (b *BaseExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return false, nil
}
// ProcessResponse default implementation (no-op)
func (b *BaseExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
}
// Logger returns the extension's logger
func (b *BaseExtension) Logger() *logging.Logger {
return b.logger
}
// ExtensionConfig holds configuration for creating extensions
type ExtensionConfig struct {
Type string
Config map[string]interface{}
}
// ExtensionFactory is a function that creates an extension from config
type ExtensionFactory func(config map[string]interface{}, logger *logging.Logger) (Extension, error)
+234
View File
@@ -0,0 +1,234 @@
package extension
import (
"context"
"fmt"
"net/http"
"sort"
"sync"
"github.com/konduktor/konduktor/internal/logging"
)
// Manager manages all loaded extensions
type Manager struct {
extensions []Extension
registry map[string]ExtensionFactory
logger *logging.Logger
mu sync.RWMutex
}
// NewManager creates a new extension manager
func NewManager(logger *logging.Logger) *Manager {
m := &Manager{
extensions: make([]Extension, 0),
registry: make(map[string]ExtensionFactory),
logger: logger,
}
// Register built-in extensions
m.RegisterFactory("routing", NewRoutingExtension)
m.RegisterFactory("security", NewSecurityExtension)
m.RegisterFactory("caching", NewCachingExtension)
return m
}
// RegisterFactory registers an extension factory
func (m *Manager) RegisterFactory(name string, factory ExtensionFactory) {
m.mu.Lock()
defer m.mu.Unlock()
m.registry[name] = factory
}
// LoadExtension loads an extension by type and config
func (m *Manager) LoadExtension(extType string, config map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
factory, ok := m.registry[extType]
if !ok {
return fmt.Errorf("unknown extension type: %s", extType)
}
ext, err := factory(config, m.logger)
if err != nil {
return fmt.Errorf("failed to create extension %s: %w", extType, err)
}
if err := ext.Initialize(); err != nil {
return fmt.Errorf("failed to initialize extension %s: %w", extType, err)
}
m.extensions = append(m.extensions, ext)
// Sort by priority (lower first)
sort.Slice(m.extensions, func(i, j int) bool {
return m.extensions[i].Priority() < m.extensions[j].Priority()
})
m.logger.Info("Loaded extension", "type", extType, "name", ext.Name(), "priority", ext.Priority())
return nil
}
// AddExtension adds a pre-created extension
func (m *Manager) AddExtension(ext Extension) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := ext.Initialize(); err != nil {
return fmt.Errorf("failed to initialize extension %s: %w", ext.Name(), err)
}
m.extensions = append(m.extensions, ext)
// Sort by priority
sort.Slice(m.extensions, func(i, j int) bool {
return m.extensions[i].Priority() < m.extensions[j].Priority()
})
m.logger.Info("Added extension", "name", ext.Name(), "priority", ext.Priority())
return nil
}
// ProcessRequest runs all extensions' ProcessRequest in order
// Returns true if any extension handled the request
func (m *Manager) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
m.mu.RLock()
extensions := m.extensions
m.mu.RUnlock()
for _, ext := range extensions {
if !ext.Enabled() {
continue
}
handled, err := ext.ProcessRequest(ctx, w, r)
if err != nil {
m.logger.Error("Extension error", "extension", ext.Name(), "error", err)
// Continue to next extension on error
continue
}
if handled {
return true, nil
}
}
return false, nil
}
// ProcessResponse runs all extensions' ProcessResponse in reverse order
func (m *Manager) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
m.mu.RLock()
extensions := m.extensions
m.mu.RUnlock()
// Process in reverse order for response
for i := len(extensions) - 1; i >= 0; i-- {
ext := extensions[i]
if !ext.Enabled() {
continue
}
ext.ProcessResponse(ctx, w, r)
}
}
// Cleanup cleans up all extensions
func (m *Manager) Cleanup() {
m.mu.Lock()
defer m.mu.Unlock()
for _, ext := range m.extensions {
if err := ext.Cleanup(); err != nil {
m.logger.Error("Extension cleanup error", "extension", ext.Name(), "error", err)
}
}
m.extensions = nil
}
// GetExtension returns an extension by name
func (m *Manager) GetExtension(name string) Extension {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ext := range m.extensions {
if ext.Name() == name {
return ext
}
}
return nil
}
// Extensions returns all loaded extensions
func (m *Manager) Extensions() []Extension {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]Extension, len(m.extensions))
copy(result, m.extensions)
return result
}
// Handler returns an http.Handler that processes requests through all extensions
func (m *Manager) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Create response wrapper to capture response for ProcessResponse
wrapper := newResponseWrapper(w)
// Process request through extensions
handled, err := m.ProcessRequest(ctx, wrapper, r)
if err != nil {
m.logger.Error("Error processing request", "error", err)
}
if handled {
// Extension handled the request, process response
m.ProcessResponse(ctx, wrapper, r)
return
}
// No extension handled, pass to next handler
next.ServeHTTP(wrapper, r)
// Process response
m.ProcessResponse(ctx, wrapper, r)
})
}
// responseWrapper wraps http.ResponseWriter to allow response modification
type responseWrapper struct {
http.ResponseWriter
statusCode int
written bool
}
func newResponseWrapper(w http.ResponseWriter) *responseWrapper {
return &responseWrapper{
ResponseWriter: w,
statusCode: http.StatusOK,
}
}
func (rw *responseWrapper) WriteHeader(code int) {
if !rw.written {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
rw.written = true
}
}
func (rw *responseWrapper) Write(b []byte) (int, error) {
if !rw.written {
rw.WriteHeader(http.StatusOK)
}
return rw.ResponseWriter.Write(b)
}
func (rw *responseWrapper) StatusCode() int {
return rw.statusCode
}
+176
View File
@@ -0,0 +1,176 @@
package extension
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/konduktor/konduktor/internal/logging"
)
func newTestLogger() *logging.Logger {
logger, _ := logging.New(logging.Config{Level: "DEBUG"})
return logger
}
func TestNewManager(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
if manager == nil {
t.Fatal("Expected manager, got nil")
}
// Check built-in factories are registered
if _, ok := manager.registry["routing"]; !ok {
t.Error("Expected routing factory to be registered")
}
if _, ok := manager.registry["security"]; !ok {
t.Error("Expected security factory to be registered")
}
if _, ok := manager.registry["caching"]; !ok {
t.Error("Expected caching factory to be registered")
}
}
func TestManager_LoadExtension(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
err := manager.LoadExtension("security", map[string]interface{}{})
if err != nil {
t.Errorf("Failed to load security extension: %v", err)
}
exts := manager.Extensions()
if len(exts) != 1 {
t.Errorf("Expected 1 extension, got %d", len(exts))
}
}
func TestManager_LoadExtension_Unknown(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
err := manager.LoadExtension("unknown", map[string]interface{}{})
if err == nil {
t.Error("Expected error for unknown extension type")
}
}
func TestManager_GetExtension(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
manager.LoadExtension("security", map[string]interface{}{})
ext := manager.GetExtension("security")
if ext == nil {
t.Error("Expected to find security extension")
}
ext = manager.GetExtension("nonexistent")
if ext != nil {
t.Error("Expected nil for nonexistent extension")
}
}
func TestManager_ProcessRequest(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
// Load security extension with blocked IP
manager.LoadExtension("security", map[string]interface{}{
"blocked_ips": []interface{}{"192.168.1.1"},
})
// Create test request
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
rr := httptest.NewRecorder()
handled, err := manager.ProcessRequest(context.Background(), rr, req)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !handled {
t.Error("Expected request to be handled (blocked)")
}
if rr.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code)
}
}
func TestManager_Handler(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
// Load routing extension with a simple route
manager.LoadExtension("routing", map[string]interface{}{
"regex_locations": map[string]interface{}{
"=/health": map[string]interface{}{
"return": "200 OK",
},
},
})
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
handler := manager.Handler(baseHandler)
// Test health route
req := httptest.NewRequest("GET", "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
}
func TestManager_Priority(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
// Load extensions in any order
manager.LoadExtension("routing", map[string]interface{}{}) // Priority 50
manager.LoadExtension("security", map[string]interface{}{}) // Priority 10
manager.LoadExtension("caching", map[string]interface{}{}) // Priority 20
exts := manager.Extensions()
if len(exts) != 3 {
t.Fatalf("Expected 3 extensions, got %d", len(exts))
}
// Check order by priority
if exts[0].Name() != "security" {
t.Errorf("Expected security first, got %s", exts[0].Name())
}
if exts[1].Name() != "caching" {
t.Errorf("Expected caching second, got %s", exts[1].Name())
}
if exts[2].Name() != "routing" {
t.Errorf("Expected routing third, got %s", exts[2].Name())
}
}
func TestManager_Cleanup(t *testing.T) {
logger := newTestLogger()
manager := NewManager(logger)
manager.LoadExtension("security", map[string]interface{}{})
manager.LoadExtension("routing", map[string]interface{}{})
manager.Cleanup()
exts := manager.Extensions()
if len(exts) != 0 {
t.Errorf("Expected 0 extensions after cleanup, got %d", len(exts))
}
}
+428
View File
@@ -0,0 +1,428 @@
package extension
import (
"context"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/konduktor/konduktor/internal/logging"
"github.com/konduktor/konduktor/internal/proxy"
)
// RoutingExtension handles request routing based on patterns
type RoutingExtension struct {
BaseExtension
exactRoutes map[string]RouteConfig
regexRoutes []*regexRoute
defaultRoute *RouteConfig
staticDir string
}
// RouteConfig holds configuration for a route
type RouteConfig struct {
ProxyPass string
Root string
IndexFile string
Return string
ContentType string
Headers []string
CacheControl string
SPAFallback bool
ExcludePatterns []string
Timeout float64
}
type regexRoute struct {
pattern *regexp.Regexp
config RouteConfig
caseSensitive bool
originalExpr string
}
// NewRoutingExtension creates a new routing extension
func NewRoutingExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
ext := &RoutingExtension{
BaseExtension: NewBaseExtension("routing", 50, logger), // Middle priority
exactRoutes: make(map[string]RouteConfig),
regexRoutes: make([]*regexRoute, 0),
staticDir: "./static",
}
logger.Debug("Routing extension config", "config", config)
// Parse regex_locations from config
if locations, ok := config["regex_locations"].(map[string]interface{}); ok {
logger.Debug("Found regex_locations", "count", len(locations))
for pattern, routeCfg := range locations {
logger.Debug("Adding route", "pattern", pattern)
if rc, ok := routeCfg.(map[string]interface{}); ok {
ext.addRoute(pattern, parseRouteConfig(rc))
}
}
} else {
logger.Warn("No regex_locations found in config", "config_keys", getKeys(config))
}
// Parse static_dir if provided
if staticDir, ok := config["static_dir"].(string); ok {
ext.staticDir = staticDir
}
return ext, nil
}
func parseRouteConfig(cfg map[string]interface{}) RouteConfig {
rc := RouteConfig{
IndexFile: "index.html",
}
if v, ok := cfg["proxy_pass"].(string); ok {
rc.ProxyPass = v
}
if v, ok := cfg["root"].(string); ok {
rc.Root = v
}
if v, ok := cfg["index_file"].(string); ok {
rc.IndexFile = v
}
if v, ok := cfg["return"].(string); ok {
rc.Return = v
}
if v, ok := cfg["content_type"].(string); ok {
rc.ContentType = v
}
if v, ok := cfg["cache_control"].(string); ok {
rc.CacheControl = v
}
if v, ok := cfg["spa_fallback"].(bool); ok {
rc.SPAFallback = v
}
if v, ok := cfg["timeout"].(float64); ok {
rc.Timeout = v
}
// Parse headers
if headers, ok := cfg["headers"].([]interface{}); ok {
for _, h := range headers {
if header, ok := h.(string); ok {
rc.Headers = append(rc.Headers, header)
}
}
}
// Parse exclude_patterns
if patterns, ok := cfg["exclude_patterns"].([]interface{}); ok {
for _, p := range patterns {
if pattern, ok := p.(string); ok {
rc.ExcludePatterns = append(rc.ExcludePatterns, pattern)
}
}
}
return rc
}
func (e *RoutingExtension) addRoute(pattern string, config RouteConfig) {
switch {
case pattern == "__default__":
e.defaultRoute = &config
case strings.HasPrefix(pattern, "="):
// Exact match
path := strings.TrimPrefix(pattern, "=")
e.exactRoutes[path] = config
case strings.HasPrefix(pattern, "~*"):
// Case-insensitive regex
expr := strings.TrimPrefix(pattern, "~*")
re, err := regexp.Compile("(?i)" + expr)
if err != nil {
e.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
return
}
e.regexRoutes = append(e.regexRoutes, &regexRoute{
pattern: re,
config: config,
caseSensitive: false,
originalExpr: expr,
})
case strings.HasPrefix(pattern, "~"):
// Case-sensitive regex
expr := strings.TrimPrefix(pattern, "~")
re, err := regexp.Compile(expr)
if err != nil {
e.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
return
}
e.regexRoutes = append(e.regexRoutes, &regexRoute{
pattern: re,
config: config,
caseSensitive: true,
originalExpr: expr,
})
}
}
// ProcessRequest handles the request routing
func (e *RoutingExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
path := r.URL.Path
// 1. Check exact routes (ignore request path for proxy)
if config, ok := e.exactRoutes[path]; ok {
return e.handleRoute(w, r, config, nil, true)
}
// 2. Check regex routes
for _, route := range e.regexRoutes {
match := route.pattern.FindStringSubmatch(path)
if match != nil {
params := make(map[string]string)
names := route.pattern.SubexpNames()
for i, name := range names {
if i > 0 && name != "" && i < len(match) {
params[name] = match[i]
}
}
return e.handleRoute(w, r, route.config, params, false)
}
}
// 3. Check default route
if e.defaultRoute != nil {
return e.handleRoute(w, r, *e.defaultRoute, nil, false)
}
return false, nil
}
func (e *RoutingExtension) handleRoute(w http.ResponseWriter, r *http.Request, config RouteConfig, params map[string]string, exactMatch bool) (bool, error) {
// Handle "return" directive
if config.Return != "" {
return e.handleReturn(w, config)
}
// Handle proxy_pass
if config.ProxyPass != "" {
return e.handleProxy(w, r, config, params, exactMatch)
}
// Handle static files with root
if config.Root != "" {
return e.handleStatic(w, r, config)
}
// Handle SPA fallback
if config.SPAFallback {
return e.handleSPAFallback(w, r, config)
}
return false, nil
}
func (e *RoutingExtension) handleReturn(w http.ResponseWriter, config RouteConfig) (bool, error) {
parts := strings.SplitN(config.Return, " ", 2)
statusCode := 200
body := "OK"
if len(parts) >= 1 {
switch parts[0] {
case "200":
statusCode = 200
case "201":
statusCode = 201
case "301":
statusCode = 301
case "302":
statusCode = 302
case "400":
statusCode = 400
case "404":
statusCode = 404
case "500":
statusCode = 500
}
}
if len(parts) >= 2 {
body = parts[1]
}
contentType := "text/plain"
if config.ContentType != "" {
contentType = config.ContentType
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(statusCode)
w.Write([]byte(body))
return true, nil
}
func (e *RoutingExtension) handleProxy(w http.ResponseWriter, r *http.Request, config RouteConfig, params map[string]string, exactMatch bool) (bool, error) {
target := config.ProxyPass
// Check if target URL contains parameter placeholders
hasParams := strings.Contains(target, "{") && strings.Contains(target, "}")
// Substitute params in target URL
for key, value := range params {
target = strings.ReplaceAll(target, "{"+key+"}", value)
}
// Create proxy config
// IgnoreRequestPath=true when:
// - exact match route (=/path)
// - target URL had parameter substitutions (the target path is fully specified)
proxyConfig := &proxy.Config{
Target: target,
Headers: make(map[string]string),
IgnoreRequestPath: exactMatch || hasParams,
}
// Set timeout if specified
if config.Timeout > 0 {
proxyConfig.Timeout = time.Duration(config.Timeout * float64(time.Second))
}
// Parse headers
clientIP := getClientIP(r)
for _, header := range config.Headers {
parts := strings.SplitN(header, ": ", 2)
if len(parts) == 2 {
value := parts[1]
// Substitute params
for key, pValue := range params {
value = strings.ReplaceAll(value, "{"+key+"}", pValue)
}
// Substitute special variables
value = strings.ReplaceAll(value, "$remote_addr", clientIP)
proxyConfig.Headers[parts[0]] = value
}
}
p, err := proxy.New(proxyConfig, e.logger)
if err != nil {
e.logger.Error("Failed to create proxy", "target", target, "error", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return true, nil
}
p.ProxyRequest(w, r, params)
return true, nil
}
func (e *RoutingExtension) handleStatic(w http.ResponseWriter, r *http.Request, config RouteConfig) (bool, error) {
path := r.URL.Path
// Handle index file for root or directory paths
if path == "/" || strings.HasSuffix(path, "/") {
path = "/" + config.IndexFile
}
// Get absolute path for root dir
absRoot, err := filepath.Abs(config.Root)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return true, nil
}
filePath := filepath.Join(absRoot, filepath.Clean("/"+path))
cleanPath := filepath.Clean(filePath)
// Prevent directory traversal
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return true, nil
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return false, nil // Let other handlers try
}
// Set cache control header
if config.CacheControl != "" {
w.Header().Set("Cache-Control", config.CacheControl)
}
// Set custom headers
for _, header := range config.Headers {
parts := strings.SplitN(header, ": ", 2)
if len(parts) == 2 {
w.Header().Set(parts[0], parts[1])
}
}
http.ServeFile(w, r, filePath)
return true, nil
}
func (e *RoutingExtension) handleSPAFallback(w http.ResponseWriter, r *http.Request, config RouteConfig) (bool, error) {
path := r.URL.Path
// Check exclude patterns
for _, pattern := range config.ExcludePatterns {
if strings.HasPrefix(path, pattern) {
return false, nil
}
}
root := config.Root
if root == "" {
root = e.staticDir
}
indexFile := config.IndexFile
if indexFile == "" {
indexFile = "index.html"
}
filePath := filepath.Join(root, indexFile)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return false, nil
}
http.ServeFile(w, r, filePath)
return true, nil
}
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}
// GetMetrics returns routing metrics
func (e *RoutingExtension) GetMetrics() map[string]interface{} {
return map[string]interface{}{
"exact_routes": len(e.exactRoutes),
"regex_routes": len(e.regexRoutes),
"has_default": e.defaultRoute != nil,
}
}
func getKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
+312
View File
@@ -0,0 +1,312 @@
package extension
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/konduktor/konduktor/internal/logging"
)
// SecurityExtension provides security features like IP filtering and security headers
type SecurityExtension struct {
BaseExtension
allowedIPs map[string]bool
blockedIPs map[string]bool
allowedCIDRs []*net.IPNet
blockedCIDRs []*net.IPNet
securityHeaders map[string]string
// Rate limiting
rateLimitEnabled bool
rateLimitRequests int
rateLimitWindow time.Duration
rateLimitByIP map[string]*rateLimitEntry
rateLimitMu sync.RWMutex
}
type rateLimitEntry struct {
count int
resetTime time.Time
}
// SecurityConfig holds security extension configuration
type SecurityConfig struct {
AllowedIPs []string `yaml:"allowed_ips"`
BlockedIPs []string `yaml:"blocked_ips"`
SecurityHeaders map[string]string `yaml:"security_headers"`
RateLimit *RateLimitConfig `yaml:"rate_limit"`
}
// RateLimitConfig holds rate limiting configuration
type RateLimitConfig struct {
Enabled bool `yaml:"enabled"`
Requests int `yaml:"requests"`
Window string `yaml:"window"` // e.g., "1m", "1h"
}
// Default security headers
var defaultSecurityHeaders = map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
// NewSecurityExtension creates a new security extension
func NewSecurityExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
ext := &SecurityExtension{
BaseExtension: NewBaseExtension("security", 10, logger), // High priority (early execution)
allowedIPs: make(map[string]bool),
blockedIPs: make(map[string]bool),
allowedCIDRs: make([]*net.IPNet, 0),
blockedCIDRs: make([]*net.IPNet, 0),
securityHeaders: make(map[string]string),
rateLimitByIP: make(map[string]*rateLimitEntry),
}
// Copy default security headers
for k, v := range defaultSecurityHeaders {
ext.securityHeaders[k] = v
}
// Parse allowed_ips
if allowedIPs, ok := config["allowed_ips"].([]interface{}); ok {
for _, ip := range allowedIPs {
if ipStr, ok := ip.(string); ok {
ext.addAllowedIP(ipStr)
}
}
}
// Parse blocked_ips
if blockedIPs, ok := config["blocked_ips"].([]interface{}); ok {
for _, ip := range blockedIPs {
if ipStr, ok := ip.(string); ok {
ext.addBlockedIP(ipStr)
}
}
}
// Parse security_headers
if headers, ok := config["security_headers"].(map[string]interface{}); ok {
for k, v := range headers {
if vStr, ok := v.(string); ok {
ext.securityHeaders[k] = vStr
}
}
}
// Parse rate_limit
if rateLimit, ok := config["rate_limit"].(map[string]interface{}); ok {
if enabled, ok := rateLimit["enabled"].(bool); ok && enabled {
ext.rateLimitEnabled = true
if requests, ok := rateLimit["requests"].(int); ok {
ext.rateLimitRequests = requests
} else if requestsFloat, ok := rateLimit["requests"].(float64); ok {
ext.rateLimitRequests = int(requestsFloat)
} else {
ext.rateLimitRequests = 100 // default
}
if window, ok := rateLimit["window"].(string); ok {
if duration, err := time.ParseDuration(window); err == nil {
ext.rateLimitWindow = duration
} else {
ext.rateLimitWindow = time.Minute // default
}
} else {
ext.rateLimitWindow = time.Minute
}
logger.Info("Rate limiting enabled",
"requests", ext.rateLimitRequests,
"window", ext.rateLimitWindow.String())
}
}
return ext, nil
}
func (e *SecurityExtension) addAllowedIP(ip string) {
if strings.Contains(ip, "/") {
// CIDR notation
_, cidr, err := net.ParseCIDR(ip)
if err == nil {
e.allowedCIDRs = append(e.allowedCIDRs, cidr)
}
} else {
e.allowedIPs[ip] = true
}
}
func (e *SecurityExtension) addBlockedIP(ip string) {
if strings.Contains(ip, "/") {
// CIDR notation
_, cidr, err := net.ParseCIDR(ip)
if err == nil {
e.blockedCIDRs = append(e.blockedCIDRs, cidr)
}
} else {
e.blockedIPs[ip] = true
}
}
// ProcessRequest checks security rules
func (e *SecurityExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
clientIP := getClientIP(r)
parsedIP := net.ParseIP(clientIP)
// Check blocked IPs first
if e.isBlocked(clientIP, parsedIP) {
e.logger.Warn("Blocked request from IP", "ip", clientIP)
http.Error(w, "403 Forbidden", http.StatusForbidden)
return true, nil
}
// Check allowed IPs (if configured, only these IPs are allowed)
if len(e.allowedIPs) > 0 || len(e.allowedCIDRs) > 0 {
if !e.isAllowed(clientIP, parsedIP) {
e.logger.Warn("Access denied for IP", "ip", clientIP)
http.Error(w, "403 Forbidden", http.StatusForbidden)
return true, nil
}
}
// Check rate limit
if e.rateLimitEnabled {
if !e.checkRateLimit(clientIP) {
e.logger.Warn("Rate limit exceeded", "ip", clientIP)
w.Header().Set("Retry-After", "60")
http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
return true, nil
}
}
return false, nil
}
// ProcessResponse adds security headers to the response
func (e *SecurityExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
for header, value := range e.securityHeaders {
w.Header().Set(header, value)
}
}
func (e *SecurityExtension) isBlocked(ip string, parsedIP net.IP) bool {
// Check exact match
if e.blockedIPs[ip] {
return true
}
// Check CIDR ranges
if parsedIP != nil {
for _, cidr := range e.blockedCIDRs {
if cidr.Contains(parsedIP) {
return true
}
}
}
return false
}
func (e *SecurityExtension) isAllowed(ip string, parsedIP net.IP) bool {
// Check exact match
if e.allowedIPs[ip] {
return true
}
// Check CIDR ranges
if parsedIP != nil {
for _, cidr := range e.allowedCIDRs {
if cidr.Contains(parsedIP) {
return true
}
}
}
return false
}
func (e *SecurityExtension) checkRateLimit(ip string) bool {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
entry, exists := e.rateLimitByIP[ip]
if !exists || now.After(entry.resetTime) {
// Create new entry or reset expired one
e.rateLimitByIP[ip] = &rateLimitEntry{
count: 1,
resetTime: now.Add(e.rateLimitWindow),
}
return true
}
// Increment counter
entry.count++
return entry.count <= e.rateLimitRequests
}
// AddBlockedIP adds an IP to the blocked list at runtime
func (e *SecurityExtension) AddBlockedIP(ip string) {
e.addBlockedIP(ip)
}
// RemoveBlockedIP removes an IP from the blocked list
func (e *SecurityExtension) RemoveBlockedIP(ip string) {
delete(e.blockedIPs, ip)
}
// AddAllowedIP adds an IP to the allowed list at runtime
func (e *SecurityExtension) AddAllowedIP(ip string) {
e.addAllowedIP(ip)
}
// RemoveAllowedIP removes an IP from the allowed list
func (e *SecurityExtension) RemoveAllowedIP(ip string) {
delete(e.allowedIPs, ip)
}
// SetSecurityHeader sets or updates a security header
func (e *SecurityExtension) SetSecurityHeader(name, value string) {
e.securityHeaders[name] = value
}
// GetMetrics returns security metrics
func (e *SecurityExtension) GetMetrics() map[string]interface{} {
e.rateLimitMu.RLock()
activeRateLimits := len(e.rateLimitByIP)
e.rateLimitMu.RUnlock()
return map[string]interface{}{
"allowed_ips": len(e.allowedIPs),
"allowed_cidrs": len(e.allowedCIDRs),
"blocked_ips": len(e.blockedIPs),
"blocked_cidrs": len(e.blockedCIDRs),
"security_headers": len(e.securityHeaders),
"rate_limit_enabled": e.rateLimitEnabled,
"active_rate_limits": activeRateLimits,
}
}
// Cleanup cleans up rate limit entries periodically
func (e *SecurityExtension) Cleanup() error {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
for ip, entry := range e.rateLimitByIP {
if now.After(entry.resetTime) {
delete(e.rateLimitByIP, ip)
}
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package extension
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewSecurityExtension(t *testing.T) {
logger := newTestLogger()
ext, err := NewSecurityExtension(map[string]interface{}{}, logger)
if err != nil {
t.Fatalf("Failed to create security extension: %v", err)
}
if ext.Name() != "security" {
t.Errorf("Expected name 'security', got %s", ext.Name())
}
if ext.Priority() != 10 {
t.Errorf("Expected priority 10, got %d", ext.Priority())
}
}
func TestSecurityExtension_BlockedIP(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"blocked_ips": []interface{}{"192.168.1.100"},
}, logger)
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.100:12345"
rr := httptest.NewRecorder()
handled, err := ext.ProcessRequest(context.Background(), rr, req)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !handled {
t.Error("Expected blocked request to be handled")
}
if rr.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code)
}
}
func TestSecurityExtension_AllowedIP(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"allowed_ips": []interface{}{"192.168.1.50"},
}, logger)
// Allowed IP
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.50:12345"
rr := httptest.NewRecorder()
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
if handled {
t.Error("Expected allowed IP to pass through")
}
// Not allowed IP
req = httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.51:12345"
rr = httptest.NewRecorder()
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
if !handled {
t.Error("Expected non-allowed IP to be blocked")
}
if rr.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code)
}
}
func TestSecurityExtension_CIDR(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"blocked_ips": []interface{}{"10.0.0.0/8"},
}, logger)
// IP in blocked CIDR
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "10.1.2.3:12345"
rr := httptest.NewRecorder()
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
if !handled {
t.Error("Expected IP in blocked CIDR to be blocked")
}
// IP not in blocked CIDR
req = httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
rr = httptest.NewRecorder()
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
if handled {
t.Error("Expected IP not in blocked CIDR to pass through")
}
}
func TestSecurityExtension_SecurityHeaders(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"security_headers": map[string]interface{}{
"X-Custom-Header": "custom-value",
},
}, logger)
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
ext.ProcessResponse(context.Background(), rr, req)
// Check default headers
if rr.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("Expected X-Content-Type-Options header")
}
// Check custom header
if rr.Header().Get("X-Custom-Header") != "custom-value" {
t.Error("Expected custom header")
}
}
func TestSecurityExtension_RateLimit(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"rate_limit": map[string]interface{}{
"enabled": true,
"requests": 2,
"window": "1m",
},
}, logger)
securityExt := ext.(*SecurityExtension)
clientIP := "192.168.1.1"
// First request - should pass
if !securityExt.checkRateLimit(clientIP) {
t.Error("First request should pass")
}
// Second request - should pass
if !securityExt.checkRateLimit(clientIP) {
t.Error("Second request should pass")
}
// Third request - should be rate limited
if securityExt.checkRateLimit(clientIP) {
t.Error("Third request should be rate limited")
}
}
func TestSecurityExtension_GetMetrics(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{
"blocked_ips": []interface{}{"192.168.1.1"},
"allowed_ips": []interface{}{"192.168.1.2"},
}, logger)
securityExt := ext.(*SecurityExtension)
metrics := securityExt.GetMetrics()
if metrics["blocked_ips"].(int) != 1 {
t.Errorf("Expected 1 blocked IP, got %v", metrics["blocked_ips"])
}
if metrics["allowed_ips"].(int) != 1 {
t.Errorf("Expected 1 allowed IP, got %v", metrics["allowed_ips"])
}
}
func TestSecurityExtension_AddRemoveIPs(t *testing.T) {
logger := newTestLogger()
ext, _ := NewSecurityExtension(map[string]interface{}{}, logger)
securityExt := ext.(*SecurityExtension)
// Add blocked IP
securityExt.AddBlockedIP("192.168.1.100")
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.100:12345"
rr := httptest.NewRecorder()
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
if !handled {
t.Error("Expected dynamically blocked IP to be blocked")
}
// Remove blocked IP
securityExt.RemoveBlockedIP("192.168.1.100")
rr = httptest.NewRecorder()
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
if handled {
t.Error("Expected removed blocked IP to pass through")
}
}