diff --git a/cmd/tsgrok/main.go b/cmd/tsgrok/main.go index 0c474f0..19a05d8 100644 --- a/cmd/tsgrok/main.go +++ b/cmd/tsgrok/main.go @@ -27,7 +27,11 @@ func main() { messageBus := &util.MessageBusImpl{} funnelRegistry := funnel.NewFunnelRegistry() - httpServer := funnel.NewHttpServer(util.GetProxyHttpPort(), messageBus, funnelRegistry, serverErrorLog) + httpServer, err := funnel.NewHttpServer(util.GetProxyHttpPort(), messageBus, funnelRegistry, serverErrorLog) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating HTTP server: %v\n", err) + os.Exit(1) + } m := tui.InitialModel(funnelRegistry, serverErrorLog) diff --git a/internal/funnel/http.go b/internal/funnel/http.go index 284a299..a2cfe96 100644 --- a/internal/funnel/http.go +++ b/internal/funnel/http.go @@ -1,30 +1,18 @@ package funnel import ( - "bytes" - "errors" "fmt" - "io" + "html/template" stdlog "log" "net" "net/http" - "net/http/httputil" - "net/url" "os" "strconv" - "strings" - "time" - "github.com/google/uuid" - "github.com/jonson/tsgrok/internal/util" -) + "io/fs" -// Error variables for common failure modes -var ( - ErrInvalidFunnelPath = errors.New("invalid path format for funnel request") - ErrFunnelNotFound = errors.New("funnel not found") - ErrFunnelNotReady = errors.New("funnel has no local target configured") - ErrTargetURLParse = errors.New("failed to parse funnel target URL") + "github.com/jonson/tsgrok/internal/util" + "github.com/jonson/tsgrok/web" ) var HttpServerPath = fmt.Sprintf("/%s/", util.ProgramName) @@ -36,9 +24,16 @@ type HttpServer struct { messageBus util.MessageBus // message bus for sending messages to the program funnelRegistry *FunnelRegistry // registry of funnels logger *stdlog.Logger // logger for logging + embeddedTemplates *template.Template } -func NewHttpServer(port int, messageBus util.MessageBus, funnelRegistry *FunnelRegistry, logger *stdlog.Logger) *HttpServer { +func NewHttpServer(port int, messageBus util.MessageBus, funnelRegistry *FunnelRegistry, logger *stdlog.Logger) (*HttpServer, error) { + // Parse templates from the web.TemplatesFS, first inject a few functions + tmpl, err := loadTemplates() + if err != nil { + return nil, err + } + return &HttpServer{ port: port, mux: http.NewServeMux(), @@ -46,7 +41,8 @@ func NewHttpServer(port int, messageBus util.MessageBus, funnelRegistry *FunnelR messageBus: messageBus, funnelRegistry: funnelRegistry, logger: logger, - } + embeddedTemplates: tmpl, + }, nil } func (s *HttpServer) GetFunnelById(id string) (Funnel, error) { @@ -67,7 +63,17 @@ func (s *HttpServer) Start() error { return err } + staticFilesRoot, err := fs.Sub(web.StaticFS, "static") + if err != nil { + s.logger.Fatalf("FATAL: 'static' subdirectory not found in embedded StaticFS: %v", err) + return err + } + fileServer := http.FileServer(http.FS(staticFilesRoot)) + s.mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) + s.mux.HandleFunc(HttpServerPath, s.handleRequest) + s.mux.HandleFunc("/inspect/", s.handleFunnelInspect) + s.mux.HandleFunc("/", s.handleRoot) // do this in a goroutine, we listen in the background go func() { @@ -83,203 +89,3 @@ func (s *HttpServer) Start() error { return nil } - -func (s *HttpServer) handleRequest(w http.ResponseWriter, r *http.Request) { - pathAfterPrefix := strings.TrimPrefix(r.URL.Path, HttpServerPath) - - funnelIdAndRest, err := extractFunnelIdAndRest(pathAfterPrefix) - if err != nil { - // Check for the specific error from extraction - if errors.Is(err, ErrInvalidFunnelPath) { - http.Error(w, ErrInvalidFunnelPath.Error(), http.StatusBadRequest) - } else { - // Handle other unexpected errors during extraction - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - // serve hello requests without proxying - if funnelIdAndRest.rest == ".well-known/tsgrok/hello" { - w.WriteHeader(http.StatusOK) - _, err = w.Write([]byte("hello")) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - return - } - - funnel, err := s.GetFunnelById(funnelIdAndRest.id) - if err != nil { - http.Error(w, ErrFunnelNotFound.Error(), http.StatusNotFound) - return - } - - targetURLStr := funnel.LocalTarget() - if targetURLStr == "" { - http.Error(w, ErrFunnelNotReady.Error(), http.StatusNotFound) - return - } - - targetURL, err := url.Parse(targetURLStr) - if err != nil { - s.logger.Printf("Error parsing target URL %q: %v", targetURLStr, err) - http.Error(w, ErrTargetURLParse.Error(), http.StatusInternalServerError) - return - } - - proxy := httputil.NewSingleHostReverseProxy(targetURL) - - // need this to avoid logging to stderr - proxy.ErrorLog = s.logger - - // Define a custom Director - originalDirector := proxy.Director - - requestResponse := CaptureRequestResponse{ - ID: uuid.New().String(), - FunnelID: funnel.HTTPFunnel.id, - Timestamp: time.Now(), - } - - // this is the function that modifies the request before it is sent to the target - proxy.Director = func(req *http.Request) { - originalDirector(req) - - // read the request body, the plan is to expose this in the UI somehow, but that comes - // at the expense of increased memory usage... make this better - var reqBodyBytes []byte - var err error - if req.Body != nil && req.Body != http.NoBody { - reqBodyBytes, err = io.ReadAll(req.Body) - if err != nil { - s.logger.Printf("Error reading request body: %v\n", err) - } else { - err = req.Body.Close() - if err != nil { - s.logger.Printf("Error closing request body: %v\n", err) - } - req.Body = io.NopCloser(bytes.NewReader(reqBodyBytes)) - req.ContentLength = int64(len(reqBodyBytes)) - req.GetBody = nil - } - } - - req.URL.Scheme = targetURL.Scheme - req.URL.Host = targetURL.Host - req.URL.Path = singleJoiningSlash(targetURL.Path, funnelIdAndRest.rest) - req.Host = targetURL.Host - - if targetURL.RawPath == "" { - req.URL.RawPath = "" - } - - headers := make(map[string]string) - for k, v := range req.Header { - headers[k] = strings.Join(v, ",") - } - - requestResponse.Request = CaptureRequest{ - Method: req.Method, - URL: req.URL.String(), - Body: reqBodyBytes, - Headers: headers, - } - } - - // this is the function that modifies the response before it is sent to the client - proxy.ModifyResponse = func(resp *http.Response) error { - - headers := make(map[string]string) - for k, v := range resp.Header { - headers[k] = strings.Join(v, ",") - } - - requestResponse.Response = CaptureResponse{ - Headers: headers, - StatusCode: resp.StatusCode, - } - - var respBodyBytes []byte - var err error - if resp.Body != nil && resp.Body != http.NoBody { - respBodyBytes, err = io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } else { - err = resp.Body.Close() - if err != nil { - s.logger.Printf("Error closing response body: %v\n", err) - } - resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes)) - resp.ContentLength = int64(len(respBodyBytes)) - resp.Header.Del("Transfer-Encoding") - } - } - - requestResponse.Response.Body = respBodyBytes - requestResponse.Duration = time.Since(requestResponse.Timestamp) - return nil - } - - // Serve the request via the proxy - proxy.ServeHTTP(w, r) - - // add the request response to the list - funnel.Requests.Add(requestResponse) - - // broadcast it so UI can update - s.messageBus.Send(ProxyRequestMsg{FunnelId: funnel.HTTPFunnel.id}) -} - -type FunnelIdAndRest struct { - id string - rest string -} - -func extractFunnelIdAndRest(pathAfterPrefix string) (FunnelIdAndRest, error) { - // Check for obviously invalid paths first - if pathAfterPrefix == "" || pathAfterPrefix == "/" { - return FunnelIdAndRest{}, ErrInvalidFunnelPath - } - - // Split the remaining path by / - parts := strings.SplitN(pathAfterPrefix, "/", 2) - - funnelId := "" - rest := "" - - if len(parts) >= 1 { - funnelId = parts[0] - } - if len(parts) == 2 { - rest = parts[1] - } - - // Check if funnelId is empty after splitting (e.g., path started with /) - if funnelId == "" { - return FunnelIdAndRest{}, ErrInvalidFunnelPath // Use the specific error - } - - return FunnelIdAndRest{id: funnelId, rest: rest}, nil -} - -func singleJoiningSlash(a, b string) string { - if a == "" && b == "" { - return "/" - } - aslash := strings.HasSuffix(a, "/") - bslash := strings.HasPrefix(b, "/") - switch { - case aslash && bslash: - return a + b[1:] - case !aslash && !bslash: - // Avoid adding slash if b is empty or a is just "/" - if b == "" || a == "/" { - return a + b - } - return a + "/" + b - } - return a + b -} diff --git a/internal/funnel/http_helpers.go b/internal/funnel/http_helpers.go new file mode 100644 index 0000000..cc50047 --- /dev/null +++ b/internal/funnel/http_helpers.go @@ -0,0 +1,76 @@ +package funnel + +import ( + "strings" +) + +// funnelName is a helper to get a display name for the funnel. +func funnelName(f Funnel) string { + name := f.Name() // This method derives from RemoteTarget + if name == "" { + return f.ID() // Fallback to ID if name is empty + } + return name +} + +// findRequestInList iterates through the funnel's request list to find a request by its ID. +func findRequestInList(requestList *RequestList, requestID string) *CaptureRequestResponse { + if requestList == nil { + return nil + } + requestList.mu.Lock() + defer requestList.mu.Unlock() + currentNode := requestList.Head + for currentNode != nil { + if currentNode.Request.ID == requestID { + crCopy := currentNode.Request + return &crCopy + } + currentNode = currentNode.Next + } + return nil +} + +// singleJoiningSlash is a utility function for joining URL paths. +func singleJoiningSlash(a, b string) string { + if a == "" && b == "" { + return "/" + } + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + if b == "" || a == "/" { + return a + b + } + return a + "/" + b + } + return a + b +} + +// extractFunnelIdAndRest extracts the funnel ID and the rest of the path from a URL path string. +func extractFunnelIdAndRest(pathAfterPrefix string) (FunnelIdAndRest, error) { + if pathAfterPrefix == "" || pathAfterPrefix == "/" { + return FunnelIdAndRest{}, ErrInvalidFunnelPath + } + + parts := strings.SplitN(pathAfterPrefix, "/", 2) + + funnelId := "" + rest := "" + + if len(parts) >= 1 { + funnelId = parts[0] + } + if len(parts) == 2 { + rest = parts[1] + } + + if funnelId == "" { + return FunnelIdAndRest{}, ErrInvalidFunnelPath + } + + return FunnelIdAndRest{id: funnelId, rest: rest}, nil +} diff --git a/internal/funnel/http_inspector_handlers.go b/internal/funnel/http_inspector_handlers.go new file mode 100644 index 0000000..36c70f6 --- /dev/null +++ b/internal/funnel/http_inspector_handlers.go @@ -0,0 +1,318 @@ +package funnel + +import ( + "errors" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/jonson/tsgrok/internal/util" +) + +func (s *HttpServer) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + displayFunnels := make([]DisplayFunnel, 0, len(s.funnelRegistry.Funnels)) + for _, funnel := range s.funnelRegistry.Funnels { + df := DisplayFunnel{ + ID: funnel.HTTPFunnel.id, + LocalTarget: funnel.LocalTarget(), + RemoteURL: funnel.RemoteTarget(), + } + displayFunnels = append(displayFunnels, df) + } + + data := struct { + Title string + ProgramName string + ActiveNav string + Funnels []DisplayFunnel + }{ + Title: "Request Inspector", + ProgramName: util.ProgramName, + ActiveNav: "Inspect", + Funnels: displayFunnels, + } + + err := s.embeddedTemplates.ExecuteTemplate(w, "inspector.html", data) + if err != nil { + s.logger.Printf("Error executing inspector template: %v", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } +} + +func (s *HttpServer) handleFunnelInspect(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/inspect/") + path = strings.TrimSuffix(path, "/") + parts := strings.Split(path, "/") + + if len(parts) == 1 && parts[0] != "" { + s.serveFunnelRequestsPage(w, r, parts[0]) + return + } + + if len(parts) == 3 && parts[0] != "" && parts[1] == "request" && parts[2] != "" { + s.handleFunnelRequestDetailFragment(w, r, parts[0], parts[2]) + return + } + + if len(parts) == 5 && parts[0] != "" && parts[1] == "request" && parts[2] != "" && parts[3] == "body" && parts[4] == "request" { + s.handleFunnelRequestBodyFragment(w, r, parts[0], parts[2]) + return + } + + if len(parts) == 5 && parts[0] != "" && parts[1] == "request" && parts[2] != "" && parts[3] == "body" && parts[4] == "response" { + s.handleFunnelResponseBodyFragment(w, r, parts[0], parts[2]) + return + } + + http.NotFound(w, r) +} + +func (s *HttpServer) serveFunnelRequestsPage(w http.ResponseWriter, r *http.Request, funnelID string) { + funnel, err := s.GetFunnelById(funnelID) + if err != nil { + if errors.Is(err, ErrFunnelNotFound) { + http.Error(w, "Funnel not found", http.StatusNotFound) + } else { + s.logger.Printf("Error retrieving funnel %s: %v", funnelID, err) + http.Error(w, "Error retrieving funnel", http.StatusInternalServerError) + } + return + } + + var capturedRequests []CaptureRequestResponse + if funnel.Requests != nil { + funnel.Requests.mu.Lock() + currentNode := funnel.Requests.Head + for currentNode != nil { + capturedRequests = append(capturedRequests, currentNode.Request) + currentNode = currentNode.Next + } + funnel.Requests.mu.Unlock() + } + + data := struct { + ProgramName string + ActiveNav string + Funnel struct { + ID string + DisplayName string + LocalTarget string + RemoteURL string + } + Requests []struct { + UUID string + Method string + MethodClass string + RequestPath string + RequestURLString string + StatusClass string + StatusCode int + FormattedDuration string + } + }{ + ProgramName: util.ProgramName, + ActiveNav: "Inspect", + Funnel: struct { + ID string + DisplayName string + LocalTarget string + RemoteURL string + }{ + ID: funnel.ID(), + DisplayName: funnelName(funnel), + LocalTarget: funnel.LocalTarget(), + RemoteURL: funnel.RemoteTarget(), + }, + } + + for _, req := range capturedRequests { + statusClass := "default" + if req.Response.StatusCode >= 200 && req.Response.StatusCode < 300 { + statusClass = "2xx" + } else if req.Response.StatusCode >= 300 && req.Response.StatusCode < 400 { + statusClass = "3xx" + } else if req.Response.StatusCode >= 400 && req.Response.StatusCode < 500 { + statusClass = "4xx" + } else if req.Response.StatusCode >= 500 { + statusClass = "5xx" + } + + var requestPath string + parsedReqURL, err := url.Parse(req.Request.URL) + if err != nil { + s.logger.Printf("Error parsing request URL '%s' in serveFunnelRequestsPage: %v", req.Request.URL, err) + requestPath = req.Request.URL + } else { + requestPath = parsedReqURL.Path + if requestPath == "" { + requestPath = "/" + } + } + + data.Requests = append(data.Requests, struct { + UUID string + Method string + MethodClass string + RequestPath string + RequestURLString string + StatusClass string + StatusCode int + FormattedDuration string + }{ + UUID: req.ID, + Method: req.Request.Method, + MethodClass: "method " + strings.ToLower(req.Request.Method), + RequestPath: requestPath, + RequestURLString: req.Request.URL, + StatusClass: statusClass, + StatusCode: req.Response.StatusCode, + FormattedDuration: req.Duration.String(), + }) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + err = s.embeddedTemplates.ExecuteTemplate(w, "funnel_requests.html", data) + if err != nil { + s.logger.Printf("Error executing funnel_requests template for funnel %s: %v", funnelID, err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } +} + +func (s *HttpServer) handleFunnelRequestDetailFragment(w http.ResponseWriter, r *http.Request, funnelID string, requestID string) { + funnel, err := s.GetFunnelById(funnelID) + if err != nil { + if errors.Is(err, ErrFunnelNotFound) { + http.Error(w, "Funnel not found", http.StatusNotFound) + } else { + s.logger.Printf("Error retrieving funnel %s: %v", funnelID, err) + http.Error(w, "Error retrieving funnel", http.StatusInternalServerError) + } + return + } + + capturedRequest := findRequestInList(funnel.Requests, requestID) + + if capturedRequest == nil { + http.Error(w, "Request not found", http.StatusNotFound) + return + } + + var requestPath string + var queryParams []QueryParamEntry + parsedURL, err := url.Parse(capturedRequest.Request.URL) + if err != nil { + s.logger.Printf("Error parsing request URL string '%s': %v", capturedRequest.Request.URL, err) + requestPath = capturedRequest.Request.URL + } else { + requestPath = parsedURL.Path + if requestPath == "" { + requestPath = "/" + } + for name, values := range parsedURL.Query() { + for _, value := range values { + queryParams = append(queryParams, QueryParamEntry{Name: name, Value: value}) + } + } + } + + details := ClientRequestDetails{ + FunnelID: funnelID, + UUID: capturedRequest.ID, + Path: requestPath, + Method: capturedRequest.Request.Method, + Status: capturedRequest.Response.StatusCode, + Duration: capturedRequest.Duration.String(), + Time: capturedRequest.Timestamp.Format("2006-01-02 15:04:05"), + ClientIP: "N/A", + RequestBody: string(capturedRequest.Request.Body), + ResponseBody: string(capturedRequest.Response.Body), + QueryParams: queryParams, + } + + if xff, ok := capturedRequest.Request.Headers["X-Forwarded-For"]; ok && xff != "" { + details.ClientIP = strings.Split(xff, ",")[0] + } else if xri, ok := capturedRequest.Request.Headers["X-Real-Ip"]; ok && xri != "" { + details.ClientIP = xri + } + + requestHeaderNames := make([]string, 0, len(capturedRequest.Request.Headers)) + for name := range capturedRequest.Request.Headers { + requestHeaderNames = append(requestHeaderNames, name) + } + sort.Strings(requestHeaderNames) + for _, name := range requestHeaderNames { + details.RequestHeaders = append(details.RequestHeaders, HeaderEntry{Name: name, Value: capturedRequest.Request.Headers[name]}) + } + + responseHeaderNames := make([]string, 0, len(capturedRequest.Response.Headers)) + for name := range capturedRequest.Response.Headers { + responseHeaderNames = append(responseHeaderNames, name) + } + sort.Strings(responseHeaderNames) + for _, name := range responseHeaderNames { + details.ResponseHeaders = append(details.ResponseHeaders, HeaderEntry{Name: name, Value: capturedRequest.Response.Headers[name]}) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + err = s.embeddedTemplates.ExecuteTemplate(w, "_request_detail_content.html", details) + if err != nil { + s.logger.Printf("Error executing request detail fragment template: %v", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } +} + +func (s *HttpServer) handleFunnelRequestBodyFragment(w http.ResponseWriter, r *http.Request, funnelID string, requestID string) { + s.serveRequestOrResponseBody(w, r, funnelID, requestID, true) +} + +func (s *HttpServer) handleFunnelResponseBodyFragment(w http.ResponseWriter, r *http.Request, funnelID string, requestID string) { + s.serveRequestOrResponseBody(w, r, funnelID, requestID, false) +} + +func (s *HttpServer) serveRequestOrResponseBody(w http.ResponseWriter, r *http.Request, funnelID string, requestID string, isRequest bool) { + funnel, err := s.GetFunnelById(funnelID) + if err != nil { + if errors.Is(err, ErrFunnelNotFound) { + http.Error(w, "Funnel not found", http.StatusNotFound) + } else { + s.logger.Printf("Error retrieving funnel %s: %v", funnelID, err) + http.Error(w, "Error retrieving funnel", http.StatusInternalServerError) + } + return + } + + capturedRequest := findRequestInList(funnel.Requests, requestID) + + if capturedRequest == nil { + http.Error(w, "Request not found", http.StatusNotFound) + return + } + + var bodyStr string + if isRequest { + bodyStr = string(capturedRequest.Request.Body) + } else { + bodyStr = string(capturedRequest.Response.Body) + } + + data := struct { + Body string + }{ + Body: bodyStr, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + err = s.embeddedTemplates.ExecuteTemplate(w, "_body_content.html", data) + if err != nil { + s.logger.Printf("Error executing body content template: %v", err) + http.Error(w, "Failed to render body content", http.StatusInternalServerError) + } +} diff --git a/internal/funnel/http_proxy.go b/internal/funnel/http_proxy.go new file mode 100644 index 0000000..81b0ed3 --- /dev/null +++ b/internal/funnel/http_proxy.go @@ -0,0 +1,152 @@ +package funnel + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +func (s *HttpServer) handleRequest(w http.ResponseWriter, r *http.Request) { + pathAfterPrefix := strings.TrimPrefix(r.URL.Path, HttpServerPath) + + funnelIdAndRest, err := extractFunnelIdAndRest(pathAfterPrefix) + if err != nil { + if err == ErrInvalidFunnelPath { // Direct comparison for package-level error vars + http.Error(w, ErrInvalidFunnelPath.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + if funnelIdAndRest.rest == ".well-known/tsgrok/hello" { + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("hello")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + + funnel, err := s.GetFunnelById(funnelIdAndRest.id) + if err != nil { + if err == ErrFunnelNotFound { // Direct comparison + http.Error(w, ErrFunnelNotFound.Error(), http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) // Catch-all for other errors from GetFunnelById + } + return + } + + targetURLStr := funnel.LocalTarget() + if targetURLStr == "" { + http.Error(w, ErrFunnelNotReady.Error(), http.StatusNotFound) + return + } + + targetURL, err := url.Parse(targetURLStr) + if err != nil { + s.logger.Printf("Error parsing target URL %q: %v", targetURLStr, err) + http.Error(w, ErrTargetURLParse.Error(), http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + proxy.ErrorLog = s.logger + + originalDirector := proxy.Director + + requestResponse := CaptureRequestResponse{ + ID: uuid.New().String(), + FunnelID: funnel.HTTPFunnel.id, + Timestamp: time.Now(), + } + + proxy.Director = func(req *http.Request) { + originalDirector(req) + + var reqBodyBytes []byte + var err error + if req.Body != nil && req.Body != http.NoBody { + reqBodyBytes, err = io.ReadAll(req.Body) + if err != nil { + s.logger.Printf("Error reading request body: %v", err) + } else { + err = req.Body.Close() + if err != nil { + s.logger.Printf("Error closing request body: %v", err) + } + req.Body = io.NopCloser(bytes.NewReader(reqBodyBytes)) + req.ContentLength = int64(len(reqBodyBytes)) + req.GetBody = nil + } + } + + req.URL.Scheme = targetURL.Scheme + req.URL.Host = targetURL.Host + req.URL.Path = singleJoiningSlash(targetURL.Path, funnelIdAndRest.rest) + req.Host = targetURL.Host + + if targetURL.RawPath == "" { + req.URL.RawPath = "" + } + + headers := make(map[string]string) + for k, v := range req.Header { + headers[k] = strings.Join(v, ",") + } + + requestResponse.Request = CaptureRequest{ + Method: req.Method, + URL: req.URL.String(), + Body: reqBodyBytes, + Headers: headers, + } + } + + proxy.ModifyResponse = func(resp *http.Response) error { + headers := make(map[string]string) + for k, v := range resp.Header { + headers[k] = strings.Join(v, ",") + } + + requestResponse.Response = CaptureResponse{ + Headers: headers, + StatusCode: resp.StatusCode, + } + + var respBodyBytes []byte + var err error + if resp.Body != nil && resp.Body != http.NoBody { + respBodyBytes, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } else { + err = resp.Body.Close() + if err != nil { + s.logger.Printf("Error closing response body: %v", err) + } + resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes)) + resp.ContentLength = int64(len(respBodyBytes)) + resp.Header.Del("Transfer-Encoding") + } + } + + requestResponse.Response.Body = respBodyBytes + requestResponse.Duration = time.Since(requestResponse.Timestamp) + return nil + } + + proxy.ServeHTTP(w, r) + + funnel.Requests.Add(requestResponse) + s.messageBus.Send(ProxyRequestMsg{FunnelId: funnel.HTTPFunnel.id}) +} diff --git a/internal/funnel/http_templates.go b/internal/funnel/http_templates.go new file mode 100644 index 0000000..717271a --- /dev/null +++ b/internal/funnel/http_templates.go @@ -0,0 +1,34 @@ +package funnel + +import ( + "fmt" + "html/template" + "reflect" + "strings" + + "github.com/jonson/tsgrok/web" +) + +func loadTemplates() (*template.Template, error) { + tmpl, err := template.New("").Funcs(template.FuncMap{ + "lower": strings.ToLower, + "default": func(defaultValue interface{}, givenValue interface{}) interface{} { + // Check for zero values for common types + gv := reflect.ValueOf(givenValue) + if !gv.IsValid() || gv.IsZero() { + return defaultValue + } + // Special case for strings, as an empty string is a zero value but might be intended + // However, for typical "default" usage, empty string means use default. + if gv.Kind() == reflect.String && gv.String() == "" { + return defaultValue + } + return givenValue + }, + }).ParseFS(web.TemplatesFS, "templates/*.html") + + if err != nil { + return nil, fmt.Errorf("failed to parse template(s) from embedded fs: %w", err) + } + return tmpl, nil +} diff --git a/internal/funnel/http_types.go b/internal/funnel/http_types.go new file mode 100644 index 0000000..bbdf452 --- /dev/null +++ b/internal/funnel/http_types.go @@ -0,0 +1,53 @@ +package funnel + +import "errors" + +// Error variables for common failure modes +var ( + ErrInvalidFunnelPath = errors.New("invalid path format for funnel request") + ErrFunnelNotFound = errors.New("funnel not found") + ErrFunnelNotReady = errors.New("funnel has no local target configured") + ErrTargetURLParse = errors.New("failed to parse funnel target URL") +) + +// DisplayFunnel is used for displaying funnel information in the inspector. +type DisplayFunnel struct { + ID string + LocalTarget string + RemoteURL string +} + +// HeaderEntry is used for displaying request/response headers. +type HeaderEntry struct { + Name string + Value string +} + +// QueryParamEntry is used for displaying URL query parameters. +type QueryParamEntry struct { + Name string + Value string +} + +// ClientRequestDetails is the structure passed to the _request_detail_content.html template. +type ClientRequestDetails struct { + FunnelID string + UUID string + Path string + Method string + Status int + Duration string + Time string + ClientIP string + RequestHeaders []HeaderEntry + ResponseHeaders []HeaderEntry + RequestBody string + ResponseBody string + QueryParams []QueryParamEntry +} + +// FunnelIdAndRest holds the extracted funnel ID and the rest of the path. +type FunnelIdAndRest struct { + id string + rest string +} diff --git a/internal/funnel/types_test.go b/internal/funnel/types_test.go index 694b309..e6f655b 100644 --- a/internal/funnel/types_test.go +++ b/internal/funnel/types_test.go @@ -245,7 +245,7 @@ func TestCaptureRequestResponse_Type(t *testing.T) { {"Video MP4", "video/mp4", "video"}, {"Application Octet Stream", "application/octet-stream", "application"}, {"Weird format", "foo/bar", "foo"}, - {"Only main type", "audio", "audio"}, + {"Only main type", "audio", "audio"}, {"Empty string after split", "/", ""}, // Invalid Content-Type } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e6f150a..034758a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -843,7 +843,7 @@ func (m model) View() string { } finalView := lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Height(contentHeight).Render(mainContent), // Render content within allocated space + lipgloss.NewStyle().Height(contentHeight).Render(mainContent), footer, ) @@ -896,7 +896,12 @@ func (m model) viewListView(contentHeight int) string { m.table.SetWidth(m.width - 20) - return m.renderContent(title, m.table.View(), contentHeight, 1) + content := lipgloss.JoinVertical(lipgloss.Left, + m.table.View(), + m.webUIView(m.width), + ) + + return m.renderContent(title, content, contentHeight, 1) } // viewCreateView renders the funnel creation form @@ -1198,3 +1203,19 @@ func (m model) viewRequestDetailView(contentHeight int) string { // Use the standard renderContent helper return m.renderContent(title, content, contentHeight, 1) } + +func (m model) webUIView(width int) string { + webUILink := "http://localhost:4141" + text := fmt.Sprintf("Local Web UI: %s", webUILink) + + // Style for the Web UI bar + // Using a light foreground color, similar to footer text + // Centered, full width, with horizontal padding + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). // Light color for text + Padding(0, 1). // Minimal vertical, some horizontal padding + Width(m.width). + Align(lipgloss.Center) + + return style.Render(text) +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..acef37b --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +package web + +import "embed" + +//go:embed templates/* +var TemplatesFS embed.FS + +//go:embed all:static +var StaticFS embed.FS diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..2b3a5b7 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,728 @@ +:root { + /* TUI-specific palette */ + --tui-bg-color: #0A0E14; /* Dark background from TUI */ + --tui-text-color: #D0D5DB; /* General text color */ + --tui-accent-color: #33FFC4; /* Bright cyan/green accent */ + --tui-secondary-text-color: #7F8C8D; /* Dimmer text for headers like "Name", "Local Target" */ + --tui-border-color: var(--tui-accent-color); /* For the main content border */ + --tui-subtle-border: #2A2E34; /* Subtle border for elements like nav */ + + /* Retaining some general variables, but TUI will override where needed */ + --bg-color: var(--tui-bg-color); + --text-color: var(--tui-text-color); + --primary-accent: var(--tui-accent-color); + --secondary-accent: var(--tui-secondary-text-color); + --border-color: var(--tui-subtle-border); + --header-bg: #101419; /* Slightly off from main bg for header */ + --button-bg: #21262D; + --button-hover-bg: #30363D; + --link-color: var(--tui-accent-color); + --link-hover-color: #80FFE2; /* Lighter shade of accent for hover */ + + /* Colors for "Stable Releases" table style */ + --stable-table-header-bg: #161B22; /* Dark grey, like existing --header-bg */ + --stable-table-header-text-color: var(--tui-accent-color); /* Cyan text */ + --stable-table-row-text-color: var(--tui-text-color); /* Light grey text */ + --stable-table-link-color: var(--tui-accent-color); /* Cyan for links in cells */ + --stable-table-border-color: #21262D; /* Dark grey for cell borders */ + --stable-table-row-odd-bg: var(--tui-bg-color); /* #0A0E14, very dark */ + --stable-table-row-even-bg: #0D1117; /* Slightly lighter dark grey */ + --stable-table-row-hover-bg: #1E242C; /* Distinct dark hover */ +} + +body { + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; /* Strict monospace */ + background-color: var(--bg-color); + color: var(--text-color); + margin: 0; + padding: 0; + font-size: 15px; /* Increased from 13px for better readability */ + line-height: 1.4; +} + +.container { + width: 95%; /* TUI often uses more screen width */ + max-width: 1400px; + margin: 0 auto; + padding: 15px 0; /* Reduced padding */ +} + +/* Main content area with TUI border */ +main > .container { + border: 1px solid var(--tui-border-color); + padding: 15px; /* Padding inside the border */ + margin-top: 15px; + background-color: var(--tui-bg-color); /* Ensure bg is consistent */ +} + +header { + background-color: var(--header-bg); /* Or var(--tui-bg-color) if no distinction needed */ + border-top: 1px solid var(--tui-accent-color); + border-bottom: 1px solid var(--tui-accent-color); + padding: 0; /* Padding will be on the container */ +} + +header .container { + display: flex; + justify-content: space-between; + align-items: stretch; /* Make items stretch to fill header height */ + padding: 0; /* Remove container padding, apply to children */ + height: 45px; /* Define a fixed height for the header bar */ +} + +.program-name { + display: flex; + align-items: center; + padding: 0 15px; + border-right: 1px solid var(--tui-accent-color); /* Border separating title from nav */ + color: var(--tui-text-color); + font-size: 1em; /* Adjusted size */ + font-weight: bold; +} + +nav { + display: flex; + align-items: stretch; /* Stretch nav items vertically */ + border-left: 1px solid var(--tui-subtle-border); /* Remove if not desired */ + padding-left: 0; /* Remove nav specific padding */ +} + +nav a, +.nav-icon-placeholder { + display: flex; + align-items: center; + padding: 0 15px; + color: var(--tui-text-color); /* Use main text color for nav items */ + text-decoration: none; + border-left: 1px solid var(--tui-accent-color); /* Vertical separators */ + font-size: 0.9em; + text-transform: uppercase; + transition: color 0.2s ease, background-color 0.2s ease; +} + +nav a:first-child, /* No left border for the first text nav item if program name has right border */ +.nav-icon-placeholder:first-of-type { + /* border-left: none; Consider if .program-name border is enough */ +} + +nav a.active, +nav a:hover, +.nav-icon-placeholder:hover { + color: var(--tui-bg-color); /* Dark text on hover */ + background-color: var(--tui-accent-color); /* Accent color background on hover */ + text-decoration: none; /* Remove TUI underline */ + border-bottom-width: 1px; /* Ensure border is consistent */ +} + +/* Specific for placeholder icons if needed */ +.nav-icon-placeholder { + font-size: 1.1em; /* Icons can be slightly larger */ + text-transform: none; /* Icons are not uppercased */ +} + +main { + padding-top: 1px; /* Prevent margin collapse with bordered container */ +} + +h2 { /* "Active Funnels" title */ + color: var(--text-color); /* Was primary-accent, TUI title is plain */ + border-bottom: 1px solid var(--tui-subtle-border); + padding-bottom: 8px; + margin-top: 0; /* Already inside bordered container */ + margin-bottom: 15px; + font-size: 1.1em; /* Smaller, TUI-like heading */ + font-weight: normal; /* TUI headers often not bold */ +} + +/* Funnel List Styling to emulate TUI table */ +.funnel-list-wrapper { /* New wrapper if needed around funnel-list for specific TUI structure */ + /* This could hold the "Name" "Local Target" headers if they are outside the