diff --git a/AGENTS.md b/AGENTS.md index 4db31cc..e822b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ tunnel/tcp_forwarder.go — Direct net.Dial TCP forwarding tunnel/stream.go — Stream interface (io.ReadWriteCloser + Source/Target) server/reconstructed_conn.go — Replays re-serialized headers + buffered body + raw conn after hijack store/store.go — In-memory request/response recorder with pub/sub (SSE) -web/web.go — Local tunnel monitor (port 8181), SSE endpoint +web/web.go — Local tunnel monitor (port 8181), embedded static UI assets, SSE endpoint config/config.go — Reflection-based config from struct tags → flags + env vars + client config file pkg/maps/map.go — Generic sync.RWMutex-guarded map ``` diff --git a/go.mod b/go.mod index b5068b1..6ddfa61 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module reichard.io/conduit go 1.25.1 require ( - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/cobra v1.10.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect - maragu.dev/gomponents v1.2.0 // indirect + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.9 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect ) diff --git a/go.sum b/go.sum index b2a20fa..f3824b9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -7,6 +8,7 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -16,11 +18,11 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= -maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= diff --git a/store/record.go b/store/record.go index 96bfa35..bb201a1 100644 --- a/store/record.go +++ b/store/record.go @@ -17,13 +17,21 @@ type TunnelRecord struct { Status int SourceAddr string - RequestHeaders http.Header - RequestBodyType string - RequestBody []byte + RequestHeaders http.Header + RequestBodyType string + RequestBody []byte + RequestBodySize int64 + RequestBodyCaptured bool + RequestBodyTruncated bool + RequestBodySkipped string - ResponseHeaders http.Header - ResponseBodyType string - ResponseBody []byte + ResponseHeaders http.Header + ResponseBodyType string + ResponseBody []byte + ResponseBodySize int64 + ResponseBodyCaptured bool + ResponseBodyTruncated bool + ResponseBodySkipped string } func (tr *TunnelRecord) MarshalJSON() ([]byte, error) { diff --git a/store/store.go b/store/store.go index 452a77e..f5b9e44 100644 --- a/store/store.go +++ b/store/store.go @@ -16,6 +16,7 @@ import ( const ( defaultQueueSize = 100 maxQueueSize = 100 + maxBodyCapture = 1024 * 1024 ) var ErrRecordNotFound = errors.New("record not found") @@ -98,11 +99,15 @@ func (s *tunnelStoreImpl) RecordRequest(req *http.Request, sourceAddress string) SourceAddr: sourceAddress, RequestHeaders: req.Header, RequestBodyType: req.Header.Get("Content-Type"), + RequestBodySize: req.ContentLength, } - if bodyData, err := getRequestBody(req); err == nil { - rec.RequestBody = bodyData - } + bodyData, meta := captureBody(&req.Body, req.Header.Get("Content-Type"), req.ContentLength, false) + rec.RequestBody = bodyData + rec.RequestBodySize = meta.size + rec.RequestBodyCaptured = meta.captured + rec.RequestBodyTruncated = meta.truncated + rec.RequestBodySkipped = meta.skipped // Add Record & Truncate s.orderedRecords = append(s.orderedRecords, rec) @@ -122,10 +127,14 @@ func (s *tunnelStoreImpl) RecordResponse(resp *http.Response) error { rec.Status = resp.StatusCode rec.ResponseHeaders = resp.Header rec.ResponseBodyType = resp.Header.Get("Content-Type") + rec.ResponseBodySize = resp.ContentLength - if bodyData, err := getResponseBody(resp); err == nil { - rec.ResponseBody = bodyData - } + bodyData, meta := captureBody(&resp.Body, resp.Header.Get("Content-Type"), resp.ContentLength, true) + rec.ResponseBody = bodyData + rec.ResponseBodySize = meta.size + rec.ResponseBodyCaptured = meta.captured + rec.ResponseBodyTruncated = meta.truncated + rec.ResponseBodySkipped = meta.skipped s.broadcast(rec) @@ -156,44 +165,71 @@ func (s *tunnelStoreImpl) broadcast(record *TunnelRecord) { s.subs = active } -func getRequestBody(req *http.Request) ([]byte, error) { - if req.ContentLength == 0 || req.Body == nil || req.Body == http.NoBody { - return nil, nil - } - - if !isTextContentType(req.Header.Get("Content-Type")) { - return nil, nil - } - - // Read Body - bodyBytes, err := io.ReadAll(req.Body) - if err != nil { - return nil, err - } - - // Restore Body - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - return bodyBytes, nil +type bodyCaptureMeta struct { + size int64 + captured bool + truncated bool + skipped string } -func getResponseBody(resp *http.Response) ([]byte, error) { - if resp.ContentLength == 0 || resp.Body == nil || resp.Body == http.NoBody { - return nil, nil +func captureBody(body *io.ReadCloser, contentType string, contentLength int64, allowImages bool) ([]byte, bodyCaptureMeta) { + meta := bodyCaptureMeta{size: contentLength} + if contentLength == 0 || *body == nil || *body == http.NoBody { + return nil, meta } - if !isTextContentType(resp.Header.Get("Content-Type")) { - return nil, nil + previewable := isTextContentType(contentType) || (allowImages && isImageContentType(contentType)) + if !previewable { + meta.skipped = "body content type is not previewable" + return nil, meta } - // Read Body - bodyBytes, err := io.ReadAll(resp.Body) + if isImageContentType(contentType) && contentLength > maxBodyCapture { + meta.skipped = "image body is too large to preview" + return nil, meta + } + + // Capture Bounded Prefix + originalBody := *body + limit := int64(maxBodyCapture + 1) + captured, err := io.ReadAll(io.LimitReader(originalBody, limit)) if err != nil { - return nil, err + meta.skipped = "failed to read body" + *body = originalBody + return nil, meta + } + + if meta.size < 0 && len(captured) <= maxBodyCapture { + meta.size = int64(len(captured)) } // Restore Body - resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - return bodyBytes, nil + *body = &replayReadCloser{ + Reader: io.MultiReader(bytes.NewReader(captured), originalBody), + closer: originalBody, + } + + if len(captured) > maxBodyCapture { + meta.truncated = true + captured = captured[:maxBodyCapture] + } + + if isImageContentType(contentType) && meta.truncated { + meta.skipped = "image body is too large to preview" + return nil, meta + } + + meta.captured = len(captured) > 0 + return captured, meta +} + +type replayReadCloser struct { + io.Reader + closer io.Closer +} + +func (r *replayReadCloser) Close() error { + return r.closer.Close() } func isTextContentType(contentType string) bool { @@ -206,14 +242,44 @@ func isTextContentType(contentType string) bool { return true } + if strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") { + return true + } + switch mediaType { case "application/json": return true case "application/xml": return true + case "application/javascript": + return true + case "application/x-javascript": + return true case "application/x-www-form-urlencoded": return true default: return false } } + +func isImageContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + switch mediaType { + case "image/png": + return true + case "image/jpeg": + return true + case "image/gif": + return true + case "image/webp": + return true + case "image/svg+xml": + return true + default: + return false + } +} diff --git a/web/pages/network.go b/web/pages/network.go index be5c6ea..4a4ad92 100644 --- a/web/pages/network.go +++ b/web/pages/network.go @@ -1,231 +1,17 @@ package pages -import ( - g "maragu.dev/gomponents" - h "maragu.dev/gomponents/html" +import _ "embed" - _ "embed" -) +//go:embed network.html +var networkHTML string //go:embed networkScript.js -var alpineScript string +var networkScript string -func NetworkPage() g.Node { - return h.Doctype( - h.HTML( - h.Head( - h.Script(g.Raw(alpineScript)), - h.Script(g.Attr("src", "//cdn.tailwindcss.com")), - h.Script(g.Attr("src", "//cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")), - ), - h.Body( - h.Div(h.Class("bg-gray-900 text-gray-100"), - networkMonitor(), - ), - ), - ), - ) +func NetworkHTML() string { + return networkHTML } -func networkMonitor() g.Node { - return h.Div( - g.Attr("x-data", "networkMonitor()"), - h.Class("h-dvh flex flex-col"), - - // Header - h.Div( - h.Class("bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center gap-4"), - h.H1(h.Class("text-lg font-semibold"), g.Text("Network")), - h.Button( - g.Attr("@click", "clear()"), - h.Class("px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm"), - g.Text("Clear"), - ), - h.Div( - h.Class("ml-auto text-sm text-gray-400"), - h.Span(g.Attr("x-text", "requests.length")), - g.Text(" requests"), - ), - ), - - // Table - h.Div( - h.Class("flex-1 overflow-auto"), - h.Table( - h.Class("w-full text-sm"), - networkTableHeader(), - networkTableBody(), - ), - ), - - // Details Panel - networkDetailsPanel(), - ) -} - -func networkTableHeader() g.Node { - return h.THead( - h.Class("bg-gray-800 sticky top-0 border-b border-gray-700"), - h.Tr( - h.Class("text-left"), - h.Th(h.Class("px-4 py-2 font-medium"), g.Text("Name")), - h.Th(h.Class("px-4 py-2 font-medium w-20"), g.Text("Method")), - h.Th(h.Class("px-4 py-2 font-medium w-20"), g.Text("Status")), - h.Th(h.Class("px-4 py-2 font-medium w-32"), g.Text("Type")), - h.Th(h.Class("px-4 py-2 font-medium w-32"), g.Text("Time")), - ), - ) -} - -func networkTableBody() g.Node { - return h.TBody( - h.Template( - g.Attr("x-for", "req in requests"), - g.Attr(":key", "req.ID"), - h.Tr( - g.Attr("@click", "selected = req"), - g.Attr(":class", "selected?.ID === req.ID ? 'bg-blue-900' : 'hover:bg-gray-800'"), - h.Class("border-b border-gray-800 cursor-pointer"), - h.Td( - h.Class("px-4 py-2 truncate max-w-md"), - g.Attr("x-text", "req.URL?.Path || req.URL"), - ), - h.Td(h.Class("px-4 py-2"), g.Attr("x-text", "req.Method")), - h.Td( - h.Class("px-4 py-2"), - h.Span( - g.Attr(":class", "statusColor(req.Status)"), - g.Attr("x-text", "req.Status || '-'"), - ), - ), - h.Td( - h.Class("px-4 py-2 text-gray-400"), - g.Attr("x-text", "req.ResponseBodyType || '-'"), - ), - h.Td( - h.Class("px-4 py-2 text-gray-400"), - g.Attr("x-text", "formatTime(req.Time)"), - ), - ), - ), - ) -} -func networkDetailsPanel() g.Node { - return h.Div( - g.Attr("x-show", "selected"), - g.Attr("x-data", "{ activeTab: 'general' }"), - h.Class("fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"), - g.Attr("@click.self", "selected = null"), - h.Div( - h.Class("bg-gray-800 rounded-lg shadow-xl w-3/4 h-3/4 flex flex-col"), - g.Attr("@click.stop", ""), - // Header - h.Div( - h.Class("flex items-center justify-between p-4 border-b border-gray-700"), - h.H2( - h.Class("text-lg font-semibold"), - g.Attr("x-text", "selected?.URL"), - ), - h.Button( - g.Attr("@click", "selected = null"), - h.Class("text-gray-400 hover:text-gray-200"), - g.Text("X"), - ), - ), - // Tabs - h.Div( - h.Class("flex border-b border-gray-700"), - tab("general", "General"), - tab("request", "Request"), - tab("response", "Response"), - ), - // Content - h.Div( - h.Class("flex-1 overflow-auto p-4"), - generalTabContent(), - requestTabContent(), - responseTabContent(), - ), - ), - ) -} - -func tab(name, label string) g.Node { - return h.Button( - g.Attr("@click", "activeTab = '"+name+"'"), - g.Attr(":class", "activeTab === '"+name+"' ? 'border-b-2 border-blue-500 text-blue-500' : 'text-gray-400 hover:text-gray-200'"), - h.Class("px-4 py-2 font-medium"), - g.Text(label), - ) -} - -func generalTabContent() g.Node { - return h.Div( - g.Attr("x-show", "activeTab === 'general'"), - h.Class("space-y-4"), - h.H3(h.Class("font-medium"), g.Text("Details")), - h.Div( - h.Class("text-sm space-y-1 text-gray-300"), - detailRow("URL:", "selected?.URL"), - detailRow("Source:", "selected?.SourceAddr"), - detailRow("Method:", "selected?.Method"), - detailRow("Status:", "selected?.Status"), - ), - ) -} - -func requestTabContent() g.Node { - return h.Div( - g.Attr("x-show", "activeTab === 'request'"), - h.Class("space-y-4"), - h.H3(h.Class("font-medium"), g.Text("Headers")), - h.Div( - h.Class("text-sm space-y-1 text-gray-300 font-mono"), - h.Template( - g.Attr("x-for", "(values, key) in selected?.RequestHeaders"), - h.Div( - h.Span(h.Class("text-gray-500"), g.Attr("x-text", "key + ':'")), - g.Text(" "), - h.Span(g.Attr("x-text", "values.join(', ')")), - ), - ), - ), - h.H3(h.Class("font-medium"), g.Text("Body")), - h.Pre( - h.Class("text-sm text-gray-300 font-mono bg-gray-900 p-3 rounded overflow-auto max-h-96"), - h.Code(g.Attr("x-text", "formatData(selected?.RequestBody)")), - ), - ) -} - -func responseTabContent() g.Node { - return h.Div( - g.Attr("x-show", "activeTab === 'response'"), - h.Class("space-y-4"), - h.H3(h.Class("font-medium"), g.Text("Headers")), - h.Div( - h.Class("text-sm space-y-1 text-gray-300 font-mono mb-4"), - h.Template( - g.Attr("x-for", "(values, key) in selected?.ResponseHeaders"), - h.Div( - h.Span(h.Class("text-gray-500"), g.Attr("x-text", "key + ':'")), - g.Text(" "), - h.Span(g.Attr("x-text", "values.join(', ')")), - ), - ), - ), - h.H3(h.Class("font-medium"), g.Text("Body")), - h.Pre( - h.Class("text-sm text-gray-300 font-mono bg-gray-900 p-3 rounded overflow-auto max-h-96"), - h.Code(g.Attr("x-text", "formatData(selected?.ResponseBody)")), - ), - ) -} - -func detailRow(label, value string) g.Node { - return h.Div( - h.Span(h.Class("text-gray-500"), g.Text(label)), - g.Text(" "), - h.Span(g.Attr("x-text", value)), - ) +func NetworkScript() string { + return networkScript } diff --git a/web/pages/network.html b/web/pages/network.html new file mode 100644 index 0000000..302244a --- /dev/null +++ b/web/pages/network.html @@ -0,0 +1,13 @@ + + +
+ + +Live tunnel traffic inspector
+Send traffic through a tunnel, then select a request to inspect headers, bodies, and previews.
+${escapeHTML(req.URL || "")}
+${escapeHTML(formatHeaders(headers) || "No headers")}
+ ${escapeHTML(bodySummary(req, prefix))}
+${escapeHTML(formatBody(body, req[`${prefix}BodyType`]))}
+ ${escapeHTML(formatBody(decoded, contentType))}`;
+}
+
+function renderPreviewEmpty(req) {
+ const reason = req.ResponseBodySkipped || "No captured response body is available.";
+ return `
+ ${escapeHTML(reason)}
+${escapeHTML(bodySummary(req, "Response"))}
+