proxy.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package reverseproxy
  2. import (
  3. "crypto/tls"
  4. "fmt"
  5. "html"
  6. "net"
  7. "net/http"
  8. "net/http/httputil"
  9. "net/url"
  10. "path"
  11. "strings"
  12. "time"
  13. "debtik/internal/logging"
  14. )
  15. // MatchDomain checks if a given host matches a domain pattern.
  16. // Handles port stripping, wildcards (*.example.com), exact matching, and global wildcards (*).
  17. func MatchDomain(pattern, host string) bool {
  18. pattern = strings.ToLower(strings.TrimSpace(pattern))
  19. host = strings.ToLower(strings.TrimSpace(host))
  20. // Strip port from host if present
  21. if h, _, err := net.SplitHostPort(host); err == nil {
  22. host = h
  23. }
  24. if pattern == "*" || pattern == "" {
  25. return true
  26. }
  27. if pattern == host {
  28. return true
  29. }
  30. // Wildcard domain: *.example.com matches sub.example.com
  31. if strings.HasPrefix(pattern, "*.") {
  32. root := pattern[2:]
  33. if host == root || strings.HasSuffix(host, "."+root) {
  34. return true
  35. }
  36. }
  37. return false
  38. }
  39. // MatchPath checks if request path matches the rule's path prefix.
  40. // Normalized to avoid partial segment collisions (e.g. /app1 must not match /app100).
  41. func MatchPath(rulePath, reqPath string) bool {
  42. cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/"))
  43. cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/"))
  44. if cleanRule == "/" {
  45. return true
  46. }
  47. if cleanReq == cleanRule {
  48. return true
  49. }
  50. return strings.HasPrefix(cleanReq, cleanRule+"/")
  51. }
  52. // BuildTargetURL calculates the destination URL based on targetBase, rulePath, reqPath, and stripPath.
  53. // Fully supports deep subfolder paths (e.g. /app1/module1/attribute1), trailing slashes, and query params.
  54. func BuildTargetURL(targetBase *url.URL, rulePath, reqPath, rawQuery string, stripPath bool) *url.URL {
  55. u := *targetBase // shallow copy
  56. cleanReq := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(reqPath), "/"))
  57. cleanRule := path.Clean("/" + strings.TrimPrefix(strings.TrimSpace(rulePath), "/"))
  58. targetBasePath := targetBase.Path
  59. if targetBasePath == "" {
  60. targetBasePath = "/"
  61. }
  62. var finalPath string
  63. if stripPath {
  64. // Strip rulePath from the beginning of reqPath
  65. sub := strings.TrimPrefix(cleanReq, cleanRule)
  66. if !strings.HasPrefix(sub, "/") {
  67. sub = "/" + sub
  68. }
  69. finalPath = singleJoiningSlash(targetBasePath, sub)
  70. } else {
  71. // When not stripping, if target has a specific base path (not just "/"), append relative subpath
  72. if targetBasePath == "/" {
  73. finalPath = cleanReq
  74. } else {
  75. sub := strings.TrimPrefix(cleanReq, cleanRule)
  76. if !strings.HasPrefix(sub, "/") {
  77. sub = "/" + sub
  78. }
  79. finalPath = singleJoiningSlash(targetBasePath, sub)
  80. }
  81. }
  82. // Preserve trailing slash if present in incoming request
  83. if strings.HasSuffix(reqPath, "/") && !strings.HasSuffix(finalPath, "/") {
  84. finalPath += "/"
  85. }
  86. u.Path = finalPath
  87. // Merge query parameters
  88. if targetBase.RawQuery == "" {
  89. u.RawQuery = rawQuery
  90. } else if rawQuery == "" {
  91. u.RawQuery = targetBase.RawQuery
  92. } else {
  93. u.RawQuery = targetBase.RawQuery + "&" + rawQuery
  94. }
  95. return &u
  96. }
  97. func singleJoiningSlash(a, b string) string {
  98. aslash := strings.HasSuffix(a, "/")
  99. bslash := strings.HasPrefix(b, "/")
  100. switch {
  101. case aslash && bslash:
  102. return a + b[1:]
  103. case !aslash && !bslash:
  104. return a + "/" + b
  105. }
  106. return a + b
  107. }
  108. type countingResponseWriter struct {
  109. http.ResponseWriter
  110. statusCode int
  111. bytesSent uint64
  112. }
  113. func (w *countingResponseWriter) WriteHeader(statusCode int) {
  114. w.statusCode = statusCode
  115. w.ResponseWriter.WriteHeader(statusCode)
  116. }
  117. func (w *countingResponseWriter) Write(b []byte) (int, error) {
  118. n, err := w.ResponseWriter.Write(b)
  119. w.bytesSent += uint64(n)
  120. return n, err
  121. }
  122. // NewProxyHandler creates an http.Handler that reverses-proxies traffic according to the rule.
  123. func NewProxyHandler(rule *Rule, onStats func(reqDelta, rxDelta, txDelta uint64)) (http.Handler, error) {
  124. targetURL, err := url.Parse(rule.TargetURL)
  125. if err != nil {
  126. return nil, fmt.Errorf("invalid target URL %q: %w", rule.TargetURL, err)
  127. }
  128. transport := &http.Transport{
  129. Proxy: http.ProxyFromEnvironment,
  130. DialContext: (&net.Dialer{
  131. Timeout: 15 * time.Second,
  132. KeepAlive: 30 * time.Second,
  133. }).DialContext,
  134. MaxIdleConns: 100,
  135. IdleConnTimeout: 90 * time.Second,
  136. TLSHandshakeTimeout: 10 * time.Second,
  137. ExpectContinueTimeout: 1 * time.Second,
  138. }
  139. if rule.InsecureTLS || targetURL.Scheme == "https" {
  140. transport.TLSClientConfig = &tls.Config{
  141. InsecureSkipVerify: rule.InsecureTLS,
  142. }
  143. }
  144. proxy := &httputil.ReverseProxy{
  145. Transport: transport,
  146. Director: func(req *http.Request) {
  147. dest := BuildTargetURL(targetURL, rule.Path, req.URL.Path, req.URL.RawQuery, rule.StripPath)
  148. req.URL.Scheme = dest.Scheme
  149. req.URL.Host = dest.Host
  150. req.URL.Path = dest.Path
  151. req.URL.RawPath = ""
  152. req.URL.RawQuery = dest.RawQuery
  153. origHost := req.Host
  154. if !rule.PreserveHost {
  155. req.Host = targetURL.Host
  156. }
  157. // Forwarded headers
  158. if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
  159. if prior := req.Header.Get("X-Forwarded-For"); prior != "" {
  160. req.Header.Set("X-Forwarded-For", prior+", "+clientIP)
  161. } else {
  162. req.Header.Set("X-Forwarded-For", clientIP)
  163. }
  164. req.Header.Set("X-Real-IP", clientIP)
  165. }
  166. proto := "http"
  167. if req.TLS != nil {
  168. proto = "https"
  169. }
  170. req.Header.Set("X-Forwarded-Proto", proto)
  171. req.Header.Set("X-Forwarded-Host", origHost)
  172. // Custom headers
  173. for k, v := range rule.CustomHeaders {
  174. req.Header.Set(k, v)
  175. }
  176. // WebSocket support
  177. if req.Header.Get("Upgrade") != "" {
  178. req.Header.Set("Upgrade", req.Header.Get("Upgrade"))
  179. req.Header.Set("Connection", "Upgrade")
  180. }
  181. },
  182. ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
  183. logging.Log("proxy,error", "Proxy error forwarding %s %s to %s: %v", r.Method, r.URL.Path, rule.TargetURL, err)
  184. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  185. w.WriteHeader(http.StatusBadGateway)
  186. _, _ = 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()))
  187. },
  188. }
  189. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  190. cw := &countingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
  191. var rxBytes uint64
  192. if r.ContentLength > 0 {
  193. rxBytes = uint64(r.ContentLength)
  194. }
  195. proxy.ServeHTTP(cw, r)
  196. if onStats != nil {
  197. onStats(1, rxBytes, cw.bytesSent)
  198. }
  199. }), nil
  200. }