forked from aegis/pyserveX
- 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.
493 lines
12 KiB
Go
493 lines
12 KiB
Go
// Package routing provides HTTP routing with regex support
|
|
package routing
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/konduktor/konduktor/internal/config"
|
|
"github.com/konduktor/konduktor/internal/logging"
|
|
"github.com/konduktor/konduktor/internal/proxy"
|
|
)
|
|
|
|
// RouteMatch represents a matched route with captured parameters
|
|
type RouteMatch struct {
|
|
Config map[string]interface{}
|
|
Params map[string]string
|
|
}
|
|
|
|
// RegexRoute represents a compiled regex route
|
|
type RegexRoute struct {
|
|
Pattern *regexp.Regexp
|
|
Config map[string]interface{}
|
|
CaseSensitive bool
|
|
OriginalExpr string
|
|
}
|
|
|
|
// Router handles HTTP routing with exact, regex, and default routes
|
|
type Router struct {
|
|
config *config.Config
|
|
logger *logging.Logger
|
|
mux *http.ServeMux
|
|
staticDir string
|
|
exactRoutes map[string]map[string]interface{}
|
|
regexRoutes []*RegexRoute
|
|
defaultRoute map[string]interface{}
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// New creates a new router from config
|
|
func New(cfg *config.Config, logger *logging.Logger) *Router {
|
|
staticDir := "./static"
|
|
if cfg != nil && cfg.HTTP.StaticDir != "" {
|
|
staticDir = cfg.HTTP.StaticDir
|
|
}
|
|
|
|
r := &Router{
|
|
config: cfg,
|
|
logger: logger,
|
|
mux: http.NewServeMux(),
|
|
staticDir: staticDir,
|
|
exactRoutes: make(map[string]map[string]interface{}),
|
|
regexRoutes: make([]*RegexRoute, 0),
|
|
}
|
|
|
|
// Load routes from extensions
|
|
if cfg != nil {
|
|
for _, ext := range cfg.Extensions {
|
|
if ext.Type == "routing" && ext.Config != nil {
|
|
if locations, ok := ext.Config["regex_locations"].(map[string]interface{}); ok {
|
|
for pattern, routeCfg := range locations {
|
|
if rc, ok := routeCfg.(map[string]interface{}); ok {
|
|
r.AddRoute(pattern, rc)
|
|
if logger != nil {
|
|
logger.Debug("Added route", "pattern", pattern)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
r.setupRoutes()
|
|
return r
|
|
}
|
|
|
|
// NewRouter creates a router without config (for testing)
|
|
func NewRouter(opts ...RouterOption) *Router {
|
|
r := &Router{
|
|
mux: http.NewServeMux(),
|
|
staticDir: "./static",
|
|
exactRoutes: make(map[string]map[string]interface{}),
|
|
regexRoutes: make([]*RegexRoute, 0),
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(r)
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// RouterOption is a functional option for Router
|
|
type RouterOption func(*Router)
|
|
|
|
// WithStaticDir sets the static directory
|
|
func WithStaticDir(dir string) RouterOption {
|
|
return func(r *Router) {
|
|
r.staticDir = dir
|
|
}
|
|
}
|
|
|
|
// StaticDir returns the static directory path
|
|
func (r *Router) StaticDir() string {
|
|
return r.staticDir
|
|
}
|
|
|
|
// Routes returns the regex routes (for testing)
|
|
func (r *Router) Routes() []*RegexRoute {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.regexRoutes
|
|
}
|
|
|
|
// ExactRoutes returns the exact routes (for testing)
|
|
func (r *Router) ExactRoutes() map[string]map[string]interface{} {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.exactRoutes
|
|
}
|
|
|
|
// DefaultRoute returns the default route (for testing)
|
|
func (r *Router) DefaultRoute() map[string]interface{} {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.defaultRoute
|
|
}
|
|
|
|
// AddRoute adds a route with the given pattern and config
|
|
// Pattern formats:
|
|
// - "=/path" - exact match
|
|
// - "~regex" - case-sensitive regex
|
|
// - "~*regex" - case-insensitive regex
|
|
// - "__default__" - default/fallback route
|
|
func (r *Router) AddRoute(pattern string, routeConfig map[string]interface{}) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
switch {
|
|
case pattern == "__default__":
|
|
r.defaultRoute = routeConfig
|
|
|
|
case strings.HasPrefix(pattern, "="):
|
|
// Exact match route
|
|
path := strings.TrimPrefix(pattern, "=")
|
|
r.exactRoutes[path] = routeConfig
|
|
|
|
case strings.HasPrefix(pattern, "~*"):
|
|
// Case-insensitive regex
|
|
expr := strings.TrimPrefix(pattern, "~*")
|
|
re, err := regexp.Compile("(?i)" + expr)
|
|
if err != nil {
|
|
if r.logger != nil {
|
|
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
|
}
|
|
return
|
|
}
|
|
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
|
|
Pattern: re,
|
|
Config: routeConfig,
|
|
CaseSensitive: false,
|
|
OriginalExpr: expr,
|
|
})
|
|
|
|
case strings.HasPrefix(pattern, "~"):
|
|
// Case-sensitive regex
|
|
expr := strings.TrimPrefix(pattern, "~")
|
|
re, err := regexp.Compile(expr)
|
|
if err != nil {
|
|
if r.logger != nil {
|
|
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
|
}
|
|
return
|
|
}
|
|
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
|
|
Pattern: re,
|
|
Config: routeConfig,
|
|
CaseSensitive: true,
|
|
OriginalExpr: expr,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Match finds the best matching route for a path
|
|
// Priority: exact match > regex match > default
|
|
func (r *Router) Match(path string) *RouteMatch {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
// 1. Check exact routes
|
|
if cfg, ok := r.exactRoutes[path]; ok {
|
|
return &RouteMatch{
|
|
Config: cfg,
|
|
Params: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// 2. Check regex routes
|
|
for _, route := range r.regexRoutes {
|
|
match := route.Pattern.FindStringSubmatch(path)
|
|
if match != nil {
|
|
params := make(map[string]string)
|
|
|
|
// Extract named groups
|
|
names := route.Pattern.SubexpNames()
|
|
for i, name := range names {
|
|
if i > 0 && name != "" && i < len(match) {
|
|
params[name] = match[i]
|
|
}
|
|
}
|
|
|
|
return &RouteMatch{
|
|
Config: route.Config,
|
|
Params: params,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Check default route
|
|
if r.defaultRoute != nil {
|
|
return &RouteMatch{
|
|
Config: r.defaultRoute,
|
|
Params: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupRoutes configures the routes from config
|
|
func (r *Router) setupRoutes() {
|
|
// Health check endpoint
|
|
r.mux.HandleFunc("/health", r.healthHandler)
|
|
|
|
// Setup redirect instructions from config
|
|
if r.config != nil {
|
|
for from, to := range r.config.Server.RedirectInstructions {
|
|
fromPath := from
|
|
toPath := to
|
|
r.mux.HandleFunc(fromPath, func(w http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(w, req, toPath, http.StatusMovedPermanently)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Default handler for all other routes
|
|
r.mux.HandleFunc("/", r.defaultHandler)
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
r.mux.ServeHTTP(w, req)
|
|
}
|
|
|
|
// healthHandler handles health check requests
|
|
func (r *Router) healthHandler(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
}
|
|
|
|
// defaultHandler handles requests that don't match other routes
|
|
func (r *Router) defaultHandler(w http.ResponseWriter, req *http.Request) {
|
|
path := req.URL.Path
|
|
|
|
// Try to match against configured routes
|
|
match := r.Match(path)
|
|
fmt.Printf("DEBUG defaultHandler: path=%q match=%v defaultRoute=%v\n", path, match != nil, r.defaultRoute != nil)
|
|
if match != nil {
|
|
fmt.Printf("DEBUG: matched config: %v\n", match.Config)
|
|
r.handleRouteMatch(w, req, match)
|
|
return
|
|
}
|
|
|
|
// Try to serve static file
|
|
if r.staticDir != "" {
|
|
// Get absolute path for static dir
|
|
absStaticDir, err := filepath.Abs(r.staticDir)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
filePath := filepath.Join(absStaticDir, filepath.Clean("/"+path))
|
|
cleanPath := filepath.Clean(filePath)
|
|
|
|
// Prevent directory traversal - ensure path is within static dir
|
|
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absStaticDir+string(filepath.Separator)) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
info, err := os.Stat(filePath)
|
|
if err == nil {
|
|
if info.IsDir() {
|
|
// Try index.html
|
|
indexPath := filepath.Join(filePath, "index.html")
|
|
if _, err := os.Stat(indexPath); err == nil {
|
|
http.ServeFile(w, req, indexPath)
|
|
return
|
|
}
|
|
} else {
|
|
http.ServeFile(w, req, filePath)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 404 Not Found
|
|
http.NotFound(w, req)
|
|
}
|
|
|
|
// handleRouteMatch handles a matched route
|
|
func (r *Router) handleRouteMatch(w http.ResponseWriter, req *http.Request, match *RouteMatch) {
|
|
cfg := match.Config
|
|
|
|
// Handle proxy_pass directive
|
|
if proxyTarget, ok := cfg["proxy_pass"].(string); ok {
|
|
r.handleProxyPass(w, req, proxyTarget, cfg, match.Params)
|
|
return
|
|
}
|
|
|
|
// Handle "return" directive
|
|
if ret, ok := cfg["return"].(string); ok {
|
|
parts := strings.SplitN(ret, " ", 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]
|
|
}
|
|
|
|
if ct, ok := cfg["content_type"].(string); ok {
|
|
w.Header().Set("Content-Type", ct)
|
|
} else {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
}
|
|
|
|
w.WriteHeader(statusCode)
|
|
w.Write([]byte(body))
|
|
return
|
|
}
|
|
|
|
// Handle static files with root
|
|
if root, ok := cfg["root"].(string); ok {
|
|
path := req.URL.Path
|
|
|
|
if indexFile, ok := cfg["index_file"].(string); ok {
|
|
if path == "/" || strings.HasSuffix(path, "/") {
|
|
path = "/" + indexFile
|
|
}
|
|
}
|
|
|
|
// Get absolute path for root dir
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
filePath := filepath.Join(absRoot, filepath.Clean("/"+path))
|
|
cleanPath := filepath.Clean(filePath)
|
|
|
|
// DEBUG
|
|
fmt.Printf("DEBUG: path=%q absRoot=%q filePath=%q cleanPath=%q\n", path, absRoot, filePath, cleanPath)
|
|
fmt.Printf("DEBUG: check1=%q check2=%q\n", cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator))
|
|
|
|
// Prevent directory traversal
|
|
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator)) {
|
|
fmt.Printf("DEBUG: FORBIDDEN - path not within root\n")
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if cacheControl, ok := cfg["cache_control"].(string); ok {
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
}
|
|
|
|
if headers, ok := cfg["headers"].([]interface{}); ok {
|
|
for _, h := range headers {
|
|
if header, ok := h.(string); ok {
|
|
parts := strings.SplitN(header, ": ", 2)
|
|
if len(parts) == 2 {
|
|
w.Header().Set(parts[0], parts[1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
http.ServeFile(w, req, filePath)
|
|
return
|
|
}
|
|
|
|
// Handle SPA fallback
|
|
if spaFallback, ok := cfg["spa_fallback"].(bool); ok && spaFallback {
|
|
root := r.staticDir
|
|
if rt, ok := cfg["root"].(string); ok {
|
|
root = rt
|
|
}
|
|
|
|
indexFile := "index.html"
|
|
if idx, ok := cfg["index_file"].(string); ok {
|
|
indexFile = idx
|
|
}
|
|
|
|
filePath := filepath.Join(root, indexFile)
|
|
http.ServeFile(w, req, filePath)
|
|
return
|
|
}
|
|
|
|
http.NotFound(w, req)
|
|
}
|
|
|
|
// handleProxyPass proxies the request to the target backend
|
|
func (r *Router) handleProxyPass(w http.ResponseWriter, req *http.Request, target string, cfg map[string]interface{}, params map[string]string) {
|
|
// Substitute params in target URL (e.g., {version} -> actual version)
|
|
for key, value := range params {
|
|
target = strings.ReplaceAll(target, "{"+key+"}", value)
|
|
}
|
|
|
|
// Create proxy
|
|
proxyConfig := &proxy.Config{
|
|
Target: target,
|
|
Headers: make(map[string]string),
|
|
}
|
|
|
|
// Parse headers from config
|
|
if headers, ok := cfg["headers"].([]interface{}); ok {
|
|
for _, h := range headers {
|
|
if header, ok := h.(string); ok {
|
|
parts := strings.SplitN(header, ": ", 2)
|
|
if len(parts) == 2 {
|
|
// Substitute params in header values
|
|
headerValue := parts[1]
|
|
for key, value := range params {
|
|
headerValue = strings.ReplaceAll(headerValue, "{"+key+"}", value)
|
|
}
|
|
proxyConfig.Headers[parts[0]] = headerValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
p, err := proxy.New(proxyConfig, r.logger)
|
|
if err != nil {
|
|
if r.logger != nil {
|
|
r.logger.Error("Failed to create proxy", "target", target, "error", err)
|
|
}
|
|
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
p.ProxyRequest(w, req, params)
|
|
}
|
|
|
|
// CreateRouterFromConfig creates a router from extension config
|
|
func CreateRouterFromConfig(cfg map[string]interface{}) *Router {
|
|
router := NewRouter()
|
|
|
|
if locations, ok := cfg["regex_locations"].(map[string]interface{}); ok {
|
|
for pattern, routeCfg := range locations {
|
|
if rc, ok := routeCfg.(map[string]interface{}); ok {
|
|
router.AddRoute(pattern, rc)
|
|
}
|
|
}
|
|
}
|
|
|
|
return router
|
|
}
|