Skip to content

Commit f82f7f8

Browse files
authored
Add web ui (#6)
1 parent b94a1b1 commit f82f7f8

File tree

18 files changed

+1722
-222
lines changed

18 files changed

+1722
-222
lines changed

cmd/tsgrok/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ func main() {
2727

2828
messageBus := &util.MessageBusImpl{}
2929
funnelRegistry := funnel.NewFunnelRegistry()
30-
httpServer := funnel.NewHttpServer(util.GetProxyHttpPort(), messageBus, funnelRegistry, serverErrorLog)
30+
httpServer, err := funnel.NewHttpServer(util.GetProxyHttpPort(), messageBus, funnelRegistry, serverErrorLog)
31+
if err != nil {
32+
fmt.Fprintf(os.Stderr, "Error creating HTTP server: %v\n", err)
33+
os.Exit(1)
34+
}
3135

3236
m := tui.InitialModel(funnelRegistry, serverErrorLog)
3337

internal/funnel/http.go

Lines changed: 24 additions & 218 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
11
package funnel
22

33
import (
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

3018
var 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

5248
func (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-
}

internal/funnel/http_helpers.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package funnel
2+
3+
import (
4+
"strings"
5+
)
6+
7+
// funnelName is a helper to get a display name for the funnel.
8+
func funnelName(f Funnel) string {
9+
name := f.Name() // This method derives from RemoteTarget
10+
if name == "" {
11+
return f.ID() // Fallback to ID if name is empty
12+
}
13+
return name
14+
}
15+
16+
// findRequestInList iterates through the funnel's request list to find a request by its ID.
17+
func findRequestInList(requestList *RequestList, requestID string) *CaptureRequestResponse {
18+
if requestList == nil {
19+
return nil
20+
}
21+
requestList.mu.Lock()
22+
defer requestList.mu.Unlock()
23+
currentNode := requestList.Head
24+
for currentNode != nil {
25+
if currentNode.Request.ID == requestID {
26+
crCopy := currentNode.Request
27+
return &crCopy
28+
}
29+
currentNode = currentNode.Next
30+
}
31+
return nil
32+
}
33+
34+
// singleJoiningSlash is a utility function for joining URL paths.
35+
func singleJoiningSlash(a, b string) string {
36+
if a == "" && b == "" {
37+
return "/"
38+
}
39+
aslash := strings.HasSuffix(a, "/")
40+
bslash := strings.HasPrefix(b, "/")
41+
switch {
42+
case aslash && bslash:
43+
return a + b[1:]
44+
case !aslash && !bslash:
45+
if b == "" || a == "/" {
46+
return a + b
47+
}
48+
return a + "/" + b
49+
}
50+
return a + b
51+
}
52+
53+
// extractFunnelIdAndRest extracts the funnel ID and the rest of the path from a URL path string.
54+
func extractFunnelIdAndRest(pathAfterPrefix string) (FunnelIdAndRest, error) {
55+
if pathAfterPrefix == "" || pathAfterPrefix == "/" {
56+
return FunnelIdAndRest{}, ErrInvalidFunnelPath
57+
}
58+
59+
parts := strings.SplitN(pathAfterPrefix, "/", 2)
60+
61+
funnelId := ""
62+
rest := ""
63+
64+
if len(parts) >= 1 {
65+
funnelId = parts[0]
66+
}
67+
if len(parts) == 2 {
68+
rest = parts[1]
69+
}
70+
71+
if funnelId == "" {
72+
return FunnelIdAndRest{}, ErrInvalidFunnelPath
73+
}
74+
75+
return FunnelIdAndRest{id: funnelId, rest: rest}, nil
76+
}

0 commit comments

Comments
 (0)