11package funnel
22
33import (
4- "bytes"
5- "errors"
64 "fmt"
7- "io "
5+ "html/template "
86 stdlog "log"
97 "net"
108 "net/http"
11- "net/http/httputil"
12- "net/url"
139 "os"
1410 "strconv"
15- "strings"
16- "time"
1711
18- "github.com/google/uuid"
19- "github.com/jonson/tsgrok/internal/util"
20- )
12+ "io/fs"
2113
22- // Error variables for common failure modes
23- var (
24- ErrInvalidFunnelPath = errors .New ("invalid path format for funnel request" )
25- ErrFunnelNotFound = errors .New ("funnel not found" )
26- ErrFunnelNotReady = errors .New ("funnel has no local target configured" )
27- ErrTargetURLParse = errors .New ("failed to parse funnel target URL" )
14+ "github.com/jonson/tsgrok/internal/util"
15+ "github.com/jonson/tsgrok/web"
2816)
2917
3018var HttpServerPath = fmt .Sprintf ("/%s/" , util .ProgramName )
@@ -36,17 +24,25 @@ type HttpServer struct {
3624 messageBus util.MessageBus // message bus for sending messages to the program
3725 funnelRegistry * FunnelRegistry // registry of funnels
3826 logger * stdlog.Logger // logger for logging
27+ embeddedTemplates * template.Template
3928}
4029
41- func NewHttpServer (port int , messageBus util.MessageBus , funnelRegistry * FunnelRegistry , logger * stdlog.Logger ) * HttpServer {
30+ func NewHttpServer (port int , messageBus util.MessageBus , funnelRegistry * FunnelRegistry , logger * stdlog.Logger ) (* HttpServer , error ) {
31+ // Parse templates from the web.TemplatesFS, first inject a few functions
32+ tmpl , err := loadTemplates ()
33+ if err != nil {
34+ return nil , err
35+ }
36+
4237 return & HttpServer {
4338 port : port ,
4439 mux : http .NewServeMux (),
4540 requestLimitPerFunnel : 100 ,
4641 messageBus : messageBus ,
4742 funnelRegistry : funnelRegistry ,
4843 logger : logger ,
49- }
44+ embeddedTemplates : tmpl ,
45+ }, nil
5046}
5147
5248func (s * HttpServer ) GetFunnelById (id string ) (Funnel , error ) {
@@ -67,7 +63,17 @@ func (s *HttpServer) Start() error {
6763 return err
6864 }
6965
66+ staticFilesRoot , err := fs .Sub (web .StaticFS , "static" )
67+ if err != nil {
68+ s .logger .Fatalf ("FATAL: 'static' subdirectory not found in embedded StaticFS: %v" , err )
69+ return err
70+ }
71+ fileServer := http .FileServer (http .FS (staticFilesRoot ))
72+ s .mux .Handle ("/static/" , http .StripPrefix ("/static/" , fileServer ))
73+
7074 s .mux .HandleFunc (HttpServerPath , s .handleRequest )
75+ s .mux .HandleFunc ("/inspect/" , s .handleFunnelInspect )
76+ s .mux .HandleFunc ("/" , s .handleRoot )
7177
7278 // do this in a goroutine, we listen in the background
7379 go func () {
@@ -83,203 +89,3 @@ func (s *HttpServer) Start() error {
8389
8490 return nil
8591}
86-
87- func (s * HttpServer ) handleRequest (w http.ResponseWriter , r * http.Request ) {
88- pathAfterPrefix := strings .TrimPrefix (r .URL .Path , HttpServerPath )
89-
90- funnelIdAndRest , err := extractFunnelIdAndRest (pathAfterPrefix )
91- if err != nil {
92- // Check for the specific error from extraction
93- if errors .Is (err , ErrInvalidFunnelPath ) {
94- http .Error (w , ErrInvalidFunnelPath .Error (), http .StatusBadRequest )
95- } else {
96- // Handle other unexpected errors during extraction
97- http .Error (w , err .Error (), http .StatusInternalServerError )
98- }
99- return
100- }
101-
102- // serve hello requests without proxying
103- if funnelIdAndRest .rest == ".well-known/tsgrok/hello" {
104- w .WriteHeader (http .StatusOK )
105- _ , err = w .Write ([]byte ("hello" ))
106- if err != nil {
107- http .Error (w , err .Error (), http .StatusInternalServerError )
108- return
109- }
110- return
111- }
112-
113- funnel , err := s .GetFunnelById (funnelIdAndRest .id )
114- if err != nil {
115- http .Error (w , ErrFunnelNotFound .Error (), http .StatusNotFound )
116- return
117- }
118-
119- targetURLStr := funnel .LocalTarget ()
120- if targetURLStr == "" {
121- http .Error (w , ErrFunnelNotReady .Error (), http .StatusNotFound )
122- return
123- }
124-
125- targetURL , err := url .Parse (targetURLStr )
126- if err != nil {
127- s .logger .Printf ("Error parsing target URL %q: %v" , targetURLStr , err )
128- http .Error (w , ErrTargetURLParse .Error (), http .StatusInternalServerError )
129- return
130- }
131-
132- proxy := httputil .NewSingleHostReverseProxy (targetURL )
133-
134- // need this to avoid logging to stderr
135- proxy .ErrorLog = s .logger
136-
137- // Define a custom Director
138- originalDirector := proxy .Director
139-
140- requestResponse := CaptureRequestResponse {
141- ID : uuid .New ().String (),
142- FunnelID : funnel .HTTPFunnel .id ,
143- Timestamp : time .Now (),
144- }
145-
146- // this is the function that modifies the request before it is sent to the target
147- proxy .Director = func (req * http.Request ) {
148- originalDirector (req )
149-
150- // read the request body, the plan is to expose this in the UI somehow, but that comes
151- // at the expense of increased memory usage... make this better
152- var reqBodyBytes []byte
153- var err error
154- if req .Body != nil && req .Body != http .NoBody {
155- reqBodyBytes , err = io .ReadAll (req .Body )
156- if err != nil {
157- s .logger .Printf ("Error reading request body: %v\n " , err )
158- } else {
159- err = req .Body .Close ()
160- if err != nil {
161- s .logger .Printf ("Error closing request body: %v\n " , err )
162- }
163- req .Body = io .NopCloser (bytes .NewReader (reqBodyBytes ))
164- req .ContentLength = int64 (len (reqBodyBytes ))
165- req .GetBody = nil
166- }
167- }
168-
169- req .URL .Scheme = targetURL .Scheme
170- req .URL .Host = targetURL .Host
171- req .URL .Path = singleJoiningSlash (targetURL .Path , funnelIdAndRest .rest )
172- req .Host = targetURL .Host
173-
174- if targetURL .RawPath == "" {
175- req .URL .RawPath = ""
176- }
177-
178- headers := make (map [string ]string )
179- for k , v := range req .Header {
180- headers [k ] = strings .Join (v , "," )
181- }
182-
183- requestResponse .Request = CaptureRequest {
184- Method : req .Method ,
185- URL : req .URL .String (),
186- Body : reqBodyBytes ,
187- Headers : headers ,
188- }
189- }
190-
191- // this is the function that modifies the response before it is sent to the client
192- proxy .ModifyResponse = func (resp * http.Response ) error {
193-
194- headers := make (map [string ]string )
195- for k , v := range resp .Header {
196- headers [k ] = strings .Join (v , "," )
197- }
198-
199- requestResponse .Response = CaptureResponse {
200- Headers : headers ,
201- StatusCode : resp .StatusCode ,
202- }
203-
204- var respBodyBytes []byte
205- var err error
206- if resp .Body != nil && resp .Body != http .NoBody {
207- respBodyBytes , err = io .ReadAll (resp .Body )
208- if err != nil {
209- return fmt .Errorf ("failed to read response body: %w" , err )
210- } else {
211- err = resp .Body .Close ()
212- if err != nil {
213- s .logger .Printf ("Error closing response body: %v\n " , err )
214- }
215- resp .Body = io .NopCloser (bytes .NewReader (respBodyBytes ))
216- resp .ContentLength = int64 (len (respBodyBytes ))
217- resp .Header .Del ("Transfer-Encoding" )
218- }
219- }
220-
221- requestResponse .Response .Body = respBodyBytes
222- requestResponse .Duration = time .Since (requestResponse .Timestamp )
223- return nil
224- }
225-
226- // Serve the request via the proxy
227- proxy .ServeHTTP (w , r )
228-
229- // add the request response to the list
230- funnel .Requests .Add (requestResponse )
231-
232- // broadcast it so UI can update
233- s .messageBus .Send (ProxyRequestMsg {FunnelId : funnel .HTTPFunnel .id })
234- }
235-
236- type FunnelIdAndRest struct {
237- id string
238- rest string
239- }
240-
241- func extractFunnelIdAndRest (pathAfterPrefix string ) (FunnelIdAndRest , error ) {
242- // Check for obviously invalid paths first
243- if pathAfterPrefix == "" || pathAfterPrefix == "/" {
244- return FunnelIdAndRest {}, ErrInvalidFunnelPath
245- }
246-
247- // Split the remaining path by /
248- parts := strings .SplitN (pathAfterPrefix , "/" , 2 )
249-
250- funnelId := ""
251- rest := ""
252-
253- if len (parts ) >= 1 {
254- funnelId = parts [0 ]
255- }
256- if len (parts ) == 2 {
257- rest = parts [1 ]
258- }
259-
260- // Check if funnelId is empty after splitting (e.g., path started with /)
261- if funnelId == "" {
262- return FunnelIdAndRest {}, ErrInvalidFunnelPath // Use the specific error
263- }
264-
265- return FunnelIdAndRest {id : funnelId , rest : rest }, nil
266- }
267-
268- func singleJoiningSlash (a , b string ) string {
269- if a == "" && b == "" {
270- return "/"
271- }
272- aslash := strings .HasSuffix (a , "/" )
273- bslash := strings .HasPrefix (b , "/" )
274- switch {
275- case aslash && bslash :
276- return a + b [1 :]
277- case ! aslash && ! bslash :
278- // Avoid adding slash if b is empty or a is just "/"
279- if b == "" || a == "/" {
280- return a + b
281- }
282- return a + "/" + b
283- }
284- return a + b
285- }
0 commit comments