| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- 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, "<!DOCTYPE html><html><head><title>502 Bad Gateway - DebTik</title><style>body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#f8fafc;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;} .card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:32px;max-width:500px;text-align:center;box-shadow:0 10px 25px -5px rgba(0,0,0,0.5);} h1{color:#ef4444;margin-top:0;} p{color:#94a3b8;line-height:1.6;} code{background:#0f172a;padding:2px 6px;border-radius:4px;color:#cbd5e1;}</style></head><body><div class=\"card\"><h1>502 Bad Gateway</h1><p><strong>DebTik Reverse Proxy</strong> could not connect to the upstream backend:</p><p><code>%s</code></p><p><small style=\"color:#64748b;\">%s</small></p></div></body></html>", 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
- }
|