// 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 }