package reverseproxy import ( "crypto/tls" "fmt" "html" "net" "net/http" "net/http/httputil" "net/url" "path" "strings" "time" "debtik/internal/logging" ) // MatchDomain checks if a given host matches a domain pattern. // Handles port stripping, wildcards (*.example.com), exact matching, and global wildcards (*). func MatchDomain(pattern, host string) bool { pattern = strings.ToLower(strings.TrimSpace(pattern)) host = strings.ToLower(strings.TrimSpace(host)) // Strip port from host if present if h, _, err := net.SplitHostPort(host); err == nil { host = h } if pattern == "*" || pattern == "" { return true } if pattern == host { return true } // Wildcard domain: *.example.com matches sub.example.com if strings.HasPrefix(pattern, "*.") { root := pattern[2:] if host == root || strings.HasSuffix(host, "."+root) { return true } } return false } // MatchPath checks if request path matches the rule's path prefix. // Normalized to avoid partial segment collisions (e.g. /app1 must not match /app100). func MatchPath(rulePath, reqPath string) bool { cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/")) cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/")) if cleanRule == "/" { return true } if cleanReq == cleanRule { return true } return strings.HasPrefix(cleanReq, cleanRule+"/") } // BuildTargetURL calculates the destination URL based on targetBase, rulePath, reqPath, and stripPath. // Fully supports deep subfolder paths (e.g. /app1/module1/attribute1), trailing slashes, and query params. func BuildTargetURL(targetBase *url.URL, rulePath, reqPath, rawQuery string, stripPath bool) *url.URL { u := *targetBase // shallow copy cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/")) cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/")) targetBasePath := targetBase.Path if targetBasePath == "" { targetBasePath = "/" } var finalPath string if stripPath { // Strip rulePath from the beginning of reqPath sub := strings.TrimPrefix(cleanReq, cleanRule) if !strings.HasPrefix(sub, "/") { sub = "/" + sub } finalPath = singleJoiningSlash(targetBasePath, sub) } else { // When not stripping, if target has a specific base path (not just "/"), append relative subpath if targetBasePath == "/" { finalPath = cleanReq } else { sub := strings.TrimPrefix(cleanReq, cleanRule) if !strings.HasPrefix(sub, "/") { sub = "/" + sub } finalPath = singleJoiningSlash(targetBasePath, sub) } } // Preserve trailing slash if present in incoming request if strings.HasSuffix(reqPath, "/") && !strings.HasSuffix(finalPath, "/") { finalPath += "/" } u.Path = finalPath // Merge query parameters if targetBase.RawQuery == "" { u.RawQuery = rawQuery } else if rawQuery == "" { u.RawQuery = targetBase.RawQuery } else { u.RawQuery = targetBase.RawQuery + "&" + rawQuery } return &u } func singleJoiningSlash(a, b string) string { aslash := strings.HasSuffix(a, "/") bslash := strings.HasPrefix(b, "/") switch { case aslash && bslash: return a + b[1:] case !aslash && !bslash: return a + "/" + b } return a + b } type countingResponseWriter struct { http.ResponseWriter statusCode int bytesSent uint64 } func (w *countingResponseWriter) WriteHeader(statusCode int) { w.statusCode = statusCode w.ResponseWriter.WriteHeader(statusCode) } func (w *countingResponseWriter) Write(b []byte) (int, error) { n, err := w.ResponseWriter.Write(b) w.bytesSent += uint64(n) return n, err } // NewProxyHandler creates an http.Handler that reverses-proxies traffic according to the rule. func NewProxyHandler(rule *Rule, onStats func(reqDelta, rxDelta, txDelta uint64)) (http.Handler, error) { targetURL, err := url.Parse(rule.TargetURL) if err != nil { return nil, fmt.Errorf("invalid target URL %q: %w", rule.TargetURL, err) } transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } if rule.InsecureTLS || targetURL.Scheme == "https" { transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: rule.InsecureTLS, } } proxy := &httputil.ReverseProxy{ Transport: transport, Director: func(req *http.Request) { dest := BuildTargetURL(targetURL, rule.Path, req.URL.Path, req.URL.RawQuery, rule.StripPath) req.URL.Scheme = dest.Scheme req.URL.Host = dest.Host req.URL.Path = dest.Path req.URL.RawPath = "" req.URL.RawQuery = dest.RawQuery origHost := req.Host if !rule.PreserveHost { req.Host = targetURL.Host } // Forwarded headers if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { if prior := req.Header.Get("X-Forwarded-For"); prior != "" { req.Header.Set("X-Forwarded-For", prior+", "+clientIP) } else { req.Header.Set("X-Forwarded-For", clientIP) } req.Header.Set("X-Real-IP", clientIP) } proto := "http" if req.TLS != nil { proto = "https" } req.Header.Set("X-Forwarded-Proto", proto) req.Header.Set("X-Forwarded-Host", origHost) // Custom headers for k, v := range rule.CustomHeaders { req.Header.Set(k, v) } // WebSocket support if req.Header.Get("Upgrade") != "" { req.Header.Set("Upgrade", req.Header.Get("Upgrade")) req.Header.Set("Connection", "Upgrade") } }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { logging.Log("proxy,error", "Proxy error forwarding %s %s to %s: %v", r.Method, r.URL.Path, rule.TargetURL, err) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadGateway) _, _ = fmt.Fprintf(w, "502 Bad Gateway - DebTik

502 Bad Gateway

DebTik Reverse Proxy could not connect to the upstream backend:

%s

%s

", html.EscapeString(rule.TargetURL), html.EscapeString(err.Error())) }, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cw := &countingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} var rxBytes uint64 if r.ContentLength > 0 { rxBytes = uint64(r.ContentLength) } proxy.ServeHTTP(cw, r) if onStats != nil { onStats(1, rxBytes, cw.bytesSent) } }), nil }