From c9304ea1cf9d9e64a34d2262189aafc72140e99c Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 3 May 2026 23:53:32 -0400 Subject: [PATCH] feat(web): rewrite tunnel monitor preview --- AGENTS.md | 2 +- go.mod | 18 +- go.sum | 6 +- store/record.go | 20 +- store/store.go | 134 ++++++++--- web/pages/network.go | 230 +----------------- web/pages/network.html | 13 + web/pages/networkScript.js | 473 ++++++++++++++++++++++++++++++++++--- web/web.go | 10 +- 9 files changed, 593 insertions(+), 313 deletions(-) create mode 100644 web/pages/network.html 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 @@ + + + + + + Conduit Monitor + + + +
+ + + diff --git a/web/pages/networkScript.js b/web/pages/networkScript.js index dda9162..464a2c6 100644 --- a/web/pages/networkScript.js +++ b/web/pages/networkScript.js @@ -1,47 +1,444 @@ -function networkMonitor() { - return { - requests: [], - selected: null, +const state = { + requests: [], + selectedID: null, + activeTab: "preview", + query: "", + streamStatus: "connecting", +}; - init() { - const es = new EventSource("/stream"); - es.onmessage = (e) => { - const record = JSON.parse(e.data); - const foundIdx = this.requests.findIndex((r) => r.ID === record.ID); - if (foundIdx >= 0) { - this.requests[foundIdx] = record; - } else { - this.requests.unshift(record); - } - }; - }, +const app = document.getElementById("app"); - clear() { - this.requests = []; - this.selected = null; - }, +function init() { + render(); + connectStream(); +} - statusColor(status) { - if (!status) return "text-gray-400"; - if (status < 300) return "text-green-400"; - if (status < 400) return "text-blue-400"; - if (status < 500) return "text-yellow-400"; - return "text-red-400"; - }, +function connectStream() { + const es = new EventSource("/stream"); - formatTime(time) { - return new Date(time).toLocaleTimeString(); - }, + es.onopen = () => { + state.streamStatus = "connected"; + render(); + }; + + es.onerror = () => { + state.streamStatus = "disconnected"; + render(); + }; + + es.onmessage = (event) => { + const record = JSON.parse(event.data); + const foundIdx = state.requests.findIndex((req) => req.ID === record.ID); + + if (foundIdx >= 0) { + state.requests[foundIdx] = record; + } else { + state.requests.unshift(record); + } + + render(); }; } -function formatData(base64Data) { - if (!base64Data) return ""; - try { - const decoded = atob(base64Data); - const parsed = JSON.parse(decoded); - return JSON.stringify(parsed, null, 2); - } catch { - return atob(base64Data); +function render() { + const selected = getSelected(); + + app.innerHTML = ` +
+ ${renderHeader()} +
+ ${renderRequestList()} + ${renderInspector(selected, false)} +
+ ${selected ? renderMobileSheet(selected) : ""} +
+ `; + + bindEvents(); + renderPreview(selected); +} + +function renderHeader() { + return ` +
+
+
+

Conduit Monitor

+

Live tunnel traffic inspector

+
+ ${state.streamStatus} + +
+
+ `; +} + +function renderRequestList() { + const filtered = filteredRequests(); + + return ` + + `; +} + +function renderRequestRow(req) { + const selected = req.ID === state.selectedID; + const url = parseURL(req.URL); + + return ` + + `; +} + +function renderInspector(selected, mobile) { + if (!selected) { + return ` + + `; + } + + const wrapperClass = mobile ? "flex h-full flex-col bg-slate-950" : "hidden min-h-0 flex-col bg-slate-950 lg:flex"; + return ` +
+ ${renderInspectorHeader(selected, mobile)} + ${renderTabs()} +
+ ${renderActiveTab(selected)} +
+
+ `; +} + +function renderMobileSheet(selected) { + return ` +
+ ${renderInspector(selected, true)} +
+ `; +} + +function renderInspectorHeader(req, mobile) { + const url = parseURL(req.URL); + + return ` +
+
+ ${mobile ? `` : ""} +
+
+ ${escapeHTML(req.Method || "-")} + ${req.Status || "pending"} + ${formatTime(req.Time)} +
+

${escapeHTML(url.path)}

+

${escapeHTML(req.URL || "")}

+
+ +
+
+ `; +} + +function renderTabs() { + return ` + + `; +} + +function renderActiveTab(req) { + if (state.activeTab === "overview") return renderOverview(req); + if (state.activeTab === "request") return renderMessage(req, "Request"); + if (state.activeTab === "response") return renderMessage(req, "Response"); + return `
`; +} + +function renderOverview(req) { + return ` +
+ ${summaryCard("Method", req.Method || "-")} + ${summaryCard("Status", req.Status || "pending")} + ${summaryCard("Source", req.SourceAddr || "-")} + ${summaryCard("Request Body", bodySummary(req, "Request"))} + ${summaryCard("Response Body", bodySummary(req, "Response"))} + ${summaryCard("Content Type", req.ResponseBodyType || req.RequestBodyType || "-")} +
+ `; +} + +function renderMessage(req, prefix) { + const headers = req[`${prefix}Headers`] || {}; + const body = decodeBody(req[`${prefix}Body`]); + + return ` +
+
+
+

Headers

+ +
+
${escapeHTML(formatHeaders(headers) || "No headers")}
+
+
+
+

Body

+ +
+

${escapeHTML(bodySummary(req, prefix))}

+
${escapeHTML(formatBody(body, req[`${prefix}BodyType`]))}
+
+
+ `; +} + +function renderPreview(selected) { + const roots = document.querySelectorAll("[data-preview-root]"); + if (roots.length === 0 || !selected) return; + + roots.forEach((root) => renderPreviewInto(root, selected)); +} + +function renderPreviewInto(root, selected) { + const contentType = selected.ResponseBodyType || ""; + const body = selected.ResponseBody; + + if (!selected.ResponseBodyCaptured || !body) { + root.innerHTML = renderPreviewEmpty(selected); + return; + } + + if (isImage(contentType)) { + root.innerHTML = `
Response preview
`; + return; + } + + const decoded = decodeBody(body); + if (isHTML(contentType)) { + root.innerHTML = ``; + root.querySelector("iframe").srcdoc = decoded; + return; + } + + root.innerHTML = `
${escapeHTML(formatBody(decoded, contentType))}
`; +} + +function renderPreviewEmpty(req) { + const reason = req.ResponseBodySkipped || "No captured response body is available."; + return ` +
+

Nothing to preview

+

${escapeHTML(reason)}

+

${escapeHTML(bodySummary(req, "Response"))}

+
+ `; +} + +function bindEvents() { + document.querySelectorAll("[data-select]").forEach((el) => { + el.addEventListener("click", () => { + state.selectedID = el.dataset.select; + state.activeTab = "preview"; + render(); + }); + }); + + document.querySelectorAll("[data-tab]").forEach((el) => { + el.addEventListener("click", () => { + state.activeTab = el.dataset.tab; + render(); + }); + }); + + document.querySelectorAll("[data-action='clear']").forEach((el) => { + el.addEventListener("click", () => { + state.requests = []; + state.selectedID = null; + render(); + }); + }); + + document.querySelectorAll("[data-action='close']").forEach((el) => { + el.addEventListener("click", () => { + state.selectedID = null; + render(); + }); + }); + + document.querySelectorAll("[data-copy]").forEach((el) => { + el.addEventListener("click", () => navigator.clipboard?.writeText(el.dataset.copy || "")); + }); + + const search = document.querySelector("[data-input='query']"); + if (search) { + search.addEventListener("input", (event) => { + state.query = event.target.value; + render(); + document.querySelector("[data-input='query']")?.focus(); + }); } } + +function filteredRequests() { + const query = state.query.trim().toLowerCase(); + if (!query) return state.requests; + + return state.requests.filter((req) => [ + req.URL, + req.Method, + String(req.Status || "pending"), + req.SourceAddr, + req.RequestBodyType, + req.ResponseBodyType, + ].some((value) => String(value || "").toLowerCase().includes(query))); +} + +function getSelected() { + return state.requests.find((req) => req.ID === state.selectedID) || null; +} + +function parseURL(raw) { + try { + const parsed = new URL(raw, window.location.origin); + return { host: parsed.host, path: `${parsed.pathname}${parsed.search}` || raw }; + } catch { + return { host: "", path: raw || "-" }; + } +} + +function decodeBody(base64Data) { + if (!base64Data) return ""; + const binary = atob(base64Data); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function formatBody(body, contentType = "") { + if (isJSON(contentType)) { + try { + return JSON.stringify(JSON.parse(body), null, 2); + } catch { + return body; + } + } + return body || "No body"; +} + +function formatHeaders(headers) { + return Object.entries(headers || {}) + .map(([key, values]) => `${key}: ${Array.isArray(values) ? values.join(", ") : values}`) + .join("\n"); +} + +function bodySummary(req, prefix) { + const size = req[`${prefix}BodySize`]; + const captured = req[`${prefix}BodyCaptured`]; + const truncated = req[`${prefix}BodyTruncated`]; + const skipped = req[`${prefix}BodySkipped`]; + + if (captured) return `${formatBytes(size)} captured${truncated ? " (truncated)" : ""}`; + if (skipped) return skipped; + if (size > 0) return `${formatBytes(size)} not captured`; + return "No body"; +} + +function summaryCard(label, value) { + return `
${escapeHTML(label)}
${escapeHTML(String(value))}
`; +} + +function renderEmptyList() { + return `
No requests yet. Start sending traffic through your tunnel.
`; +} + +function isJSON(contentType) { + return /(^|\/)json($|;)|\+json($|;)/i.test(contentType || ""); +} + +function isHTML(contentType) { + return /text\/html/i.test(contentType || ""); +} + +function isImage(contentType) { + return /^image\/(png|jpe?g|gif|webp|svg\+xml)/i.test(contentType || ""); +} + +function methodClass(method) { + return { + GET: "bg-emerald-500/15 text-emerald-300", + POST: "bg-blue-500/15 text-blue-300", + PUT: "bg-amber-500/15 text-amber-300", + PATCH: "bg-purple-500/15 text-purple-300", + DELETE: "bg-red-500/15 text-red-300", + }[method] || "bg-slate-700 text-slate-200"; +} + +function statusClass(status) { + if (!status) return "bg-slate-700 text-slate-300"; + if (status < 300) return "bg-emerald-500/15 text-emerald-300"; + if (status < 400) return "bg-blue-500/15 text-blue-300"; + if (status < 500) return "bg-amber-500/15 text-amber-300"; + return "bg-red-500/15 text-red-300"; +} + +function streamStatusClass() { + if (state.streamStatus === "connected") return "bg-emerald-500/15 text-emerald-300"; + if (state.streamStatus === "connecting") return "bg-amber-500/15 text-amber-300"; + return "bg-red-500/15 text-red-300"; +} + +function formatTime(time) { + if (!time) return "-"; + return new Date(time).toLocaleTimeString(); +} + +function formatBytes(bytes) { + if (!bytes || bytes < 0) return "unknown size"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} + +function escapeHTML(value) { + return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + "'": "'", + '"': """, + })[char]); +} + +function escapeAttr(value) { + return escapeHTML(value).replace(/`/g, "`"); +} + +init(); diff --git a/web/web.go b/web/web.go index 244d269..b8dc096 100644 --- a/web/web.go +++ b/web/web.go @@ -26,6 +26,7 @@ func (s *WebServer) Start(ctx context.Context) error { rootMux := http.NewServeMux() rootMux.HandleFunc("/", s.handleRoot) + rootMux.HandleFunc("/assets/network.js", s.handleNetworkScript) rootMux.HandleFunc("/stream", s.handleStream) s.server = &http.Server{ @@ -85,6 +86,11 @@ func (s *WebServer) handleStream(w http.ResponseWriter, r *http.Request) { } func (s *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - _ = pages.NetworkPage().Render(w) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(pages.NetworkHTML())) +} + +func (s *WebServer) handleNetworkScript(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(pages.NetworkScript())) }