11package proxy
22
33import (
4+ "context"
45 "fmt"
56 "github.com/SpechtLabs/StaticPages/pkg/config"
7+ "github.com/golang/groupcache/singleflight"
68 "github.com/sierrasoftworks/humane-errors-go"
9+ "github.com/spf13/viper"
710 "github.com/uptrace/opentelemetry-go-extra/otelzap"
811 "go.uber.org/zap"
912 "net/http"
1013 "net/http/httputil"
1114 "net/url"
1215 "path"
1316 "strings"
17+ "sync"
18+ "time"
1419)
1520
1621type Proxy struct {
1722 zapLog * otelzap.Logger
1823 pages map [string ]* config.Page
1924 proxy * httputil.ReverseProxy
25+ group singleflight.Group
2026}
2127
2228func NewProxy (zapLog * otelzap.Logger , pages []* config.Page ) * Proxy {
@@ -37,62 +43,55 @@ func NewProxy(zapLog *otelzap.Logger, pages []*config.Page) *Proxy {
3743 }
3844
3945 p .proxy = & httputil.ReverseProxy {
40- // Add a proxy director
41- Director : p .Director ,
42-
43- // Add error handler to log errors
44- ErrorHandler : p .ErrorHandler ,
45-
46- // Add response modifier to log response status
47- ModifyResponse : p .ModifyResponse ,
46+ Director : p .Director , // Add a proxy director
47+ ErrorHandler : p .ErrorHandler , // Add error handler to log errors
48+ ModifyResponse : p .ModifyResponse , // Add response modifier to log response status
49+
50+ // Allow transport configuration provided by user
51+ Transport : & http.Transport {
52+ MaxIdleConns : viper .GetInt ("proxy.maxIdleConns" ),
53+ MaxIdleConnsPerHost : viper .GetInt ("proxy.maxIdleConnsPerHost" ),
54+ IdleConnTimeout : viper .GetDuration ("proxy.timeout" ),
55+ DisableCompression : ! viper .GetBool ("proxy.compression" ),
56+ },
4857 }
4958
5059 return p
5160}
5261
5362func (p * Proxy ) Director (req * http.Request ) {
54- host := req .Host
63+ if req .Context ().Err () != nil {
64+ p .zapLog .Ctx (req .Context ()).Warn ("request context canceled" , zap .String ("url" , req .URL .String ()), zap .String ("path" , req .URL .Path ))
65+ return
66+ }
5567
56- if strings .Contains (host , ":" ) {
57- host = strings .Split (host , ":" )[0 ]
68+ originalPath := req .URL .Path
69+ requestUrl := req .Host
70+ if strings .Contains (requestUrl , ":" ) {
71+ requestUrl = strings .Split (requestUrl , ":" )[0 ]
5872 }
5973
60- page , ok := p .pages [host ]
74+ page , ok := p .pages [requestUrl ]
6175 if ! ok {
62- p .zapLog .Error ("no page found for host " , zap .String ("host " , host ))
76+ p .zapLog .Ctx ( req . Context ()). Error ("no page found for requestUrl " , zap .String ("requestUrl " , requestUrl ))
6377 return
6478 }
6579
66- targetURL , err := url .Parse (page .Proxy .URL .String ())
80+ backendUrl , err := url .Parse (page .Proxy .URL .String ())
6781 if err != nil {
68- p .zapLog .Error ("invalid target URL" , zap .Error (err ), zap .String ("url" , page .Proxy .URL .String ()))
82+ p .zapLog .Ctx ( req . Context ()). Error ("invalid target URL" , zap .Error (err ), zap .String ("url" , page .Proxy .URL .String ()))
6983 return
7084 }
7185
72- originalPath := req .URL .Path
73-
74- // Create a clean path without double slashes
75- targetPath := path .Clean (fmt .Sprintf ("/%s/%s" ,
76- page .Proxy .Path ,
77- originalPath ,
78- ))
79-
80- searchPath := append ([]string {"" }, page .Proxy .SearchPath ... )
81-
82- for _ , lookupPath := range searchPath {
83- testTarget := path .Clean (fmt .Sprintf ("/%s/%s" ,
84- targetPath ,
85- lookupPath ,
86- ))
87-
88- if resp , err := http .Head (targetURL .String () + testTarget ); err == nil && resp .StatusCode < http .StatusBadRequest {
89- targetPath = testTarget
90- break
91- }
86+ // Find the actual html document we are looking for
87+ targetPath , err := p .lookupPath (req .Context (), page , requestUrl , backendUrl , path .Clean (fmt .Sprintf ("/%s/%s" , page .Proxy .Path , originalPath )))
88+ if err != nil {
89+ p .zapLog .Ctx (req .Context ()).Error ("no valid path found" , zap .String ("original_path" , originalPath ), zap .String ("target_path" , targetPath ))
90+ return
9291 }
9392
94- req .URL .Scheme = targetURL .Scheme
95- req .URL .Host = targetURL .Host
93+ req .URL .Scheme = backendUrl .Scheme
94+ req .URL .Host = backendUrl .Host
9695 req .URL .Path = targetPath
9796
9897 // Clear the RequestURI as it's required for client requests
@@ -104,42 +103,50 @@ func (p *Proxy) Director(req *http.Request) {
104103 }
105104
106105 req .Header .Set ("X-Forwarded-Host" , req .Host )
107- req .Header .Set ("X-Origin-Host" , targetURL .Host )
106+ req .Header .Set ("X-Origin-Host" , backendUrl .Host )
108107
109108 // Log the request transformation
110- p .zapLog .Debug ("transforming request" ,
109+ p .zapLog .Ctx ( req . Context ()). Debug ("transforming request" ,
111110 zap .String ("original_path" , originalPath ),
112111 zap .String ("target_path" , targetPath ),
113- zap .String ("target_server" , targetURL .String ()),
112+ zap .String ("target_server" , backendUrl .String ()),
114113 zap .String ("target_url" , req .URL .String ()),
115114 )
116115}
117116
118117func (p * Proxy ) ErrorHandler (w http.ResponseWriter , r * http.Request , err error ) {
119118 responseCode := http .StatusBadGateway
120119
121- p .zapLog .Error ("proxy error" ,
122- zap .String ("error" , err .Error ()),
123- zap .String ("url" , r .URL .String ()),
124- )
125-
126120 switch err .Error () {
127121 case "context canceled" :
128122 responseCode = 499 // Nginx' non-standard code for when a client closes the connection
129123 }
130124
125+ p .zapLog .Ctx (r .Context ()).Error ("proxy error" ,
126+ zap .String ("error" , err .Error ()),
127+ zap .String ("url" , r .URL .String ()),
128+ zap .Int ("status" , responseCode ),
129+ )
130+
131131 http .Error (w , err .Error (), responseCode )
132132}
133133
134134func (p * Proxy ) ModifyResponse (r * http.Response ) error {
135135 if r .StatusCode >= 300 {
136- dump , _ := httputil .DumpResponse (r , true )
137-
138- p .zapLog .Debug ("received response" ,
139- zap .Int ("status" , r .StatusCode ),
140- zap .String ("url" , r .Request .URL .String ()),
141- zap .ByteString ("url" , dump ),
142- )
136+ if p .zapLog .Core ().Enabled (zap .DebugLevel ) {
137+ dump , _ := httputil .DumpResponse (r , true )
138+
139+ p .zapLog .Ctx (r .Request .Context ()).Debug ("received response" ,
140+ zap .Int ("status" , r .StatusCode ),
141+ zap .String ("url" , r .Request .URL .String ()),
142+ zap .ByteString ("url" , dump ),
143+ )
144+ } else {
145+ p .zapLog .Ctx (r .Request .Context ()).Info ("received response" ,
146+ zap .Int ("status" , r .StatusCode ),
147+ zap .String ("url" , r .Request .URL .String ()),
148+ )
149+ }
143150 }
144151
145152 return nil
@@ -176,3 +183,58 @@ func (p *Proxy) Serve(addr string) humane.Error {
176183
177184 return nil
178185}
186+
187+ func (p * Proxy ) probePath (ctx context.Context , url * url.URL , location string ) (int , error ) {
188+ // create a http client with short timeout for fast failure
189+ client := & http.Client {
190+ Timeout : 2 * time .Second ,
191+ }
192+
193+ req , _ := http .NewRequestWithContext (ctx , http .MethodHead , url .String ()+ location , nil )
194+ resp , err := client .Do (req )
195+ if err != nil {
196+ return http .StatusNotFound , err
197+ }
198+
199+ return resp .StatusCode , err
200+ }
201+
202+ func (p * Proxy ) lookupPath (ctx context.Context , page * config.Page , sourceHost string , backendUrl * url.URL , targetPath string ) (string , humane.Error ) {
203+ // Find the actual html document we are looking for
204+ searchPath := append ([]string {"" }, page .Proxy .SearchPath ... )
205+ var wg sync.WaitGroup
206+ validPath := make (chan string , len (searchPath ))
207+
208+ for _ , lookupPath := range searchPath {
209+ // Probe each searchPath asynchronously
210+ wg .Add (1 )
211+ go func () {
212+ defer wg .Done ()
213+
214+ // Cache search path computation
215+ cacheKey := fmt .Sprintf ("%s-%s-%s" , sourceHost , targetPath , lookupPath )
216+ _ , _ = p .group .Do (cacheKey , func () (interface {}, error ) {
217+ // Probe the path
218+ testTarget := path .Clean (fmt .Sprintf ("/%s/%s" , targetPath , lookupPath ))
219+ statusCode , err := p .probePath (ctx , backendUrl , testTarget )
220+ if err == nil && statusCode < http .StatusBadRequest {
221+ validPath <- testTarget
222+ return testTarget , nil
223+ }
224+
225+ return nil , humane .Wrap (err , "Unable to probe path" , "Make sure the path exists and is accessible." )
226+ })
227+ }()
228+ }
229+
230+ go func () {
231+ wg .Wait ()
232+ close (validPath )
233+ }()
234+
235+ if validPath , ok := <- validPath ; ok {
236+ return validPath , nil
237+ }
238+
239+ return "" , humane .New ("no valid path found" , "Make sure the path exists and is accessible." )
240+ }
0 commit comments