Files
ARTEX/server/traffic_ingest.go
BaaRai d8f47d4359 Traffic ingest: capture operator browsing via a browser extension
Closes #33. The MITM proxy only sees the agent's own actions, so
anything behind a human gate (signup captcha, email OTP, 2FA, SSO)
left the authenticated surface dark. The operator's browser becomes a
capture source instead:

- traffic: exported RecordExternal storing an externally captured
  exchange through the same spill/blob/FTS path as proxy traffic
  (insert logic shared with record()); rejects unparsable URLs and
  empty methods
- server: POST /api/traffic/ingest inside the authenticated API group,
  batch capped at 50 entries, bodies truncated at 256 KiB, per-entry
  ingested/failed counts
- extension/: Manifest V3 Chrome/Edge extension driving the
  chrome.debugger Network domain on the chosen tab, batching text
  exchanges to the ingest endpoint with the operator's URL + JWT in
  chrome.storage.local; binary bodies skipped; flush on batch/stop
- tests for both layers, README section
2026-10-11 14:53:08 +02:00

63 lines
1.7 KiB
Go

package server
import (
"encoding/json"
"net/http"
"github.com/Autumn-27/artex/traffic"
)
// Ingest caps: a browser tab can produce a burst of entries, but the ingest
// path is meant for deliberate operator captures, not bulk sync. Bodies are
// truncated server-side as a second line of defense behind the extension's
// own caps.
const (
maxIngestBatch = 50
maxIngestBodySize = 256 << 10 // 256 KiB per body
)
// ingestTraffic accepts exchanges captured outside the MITM proxy (currently
// the browser extension) into the global traffic store, so operator-driven
// authenticated browsing becomes evidence the same way proxy traffic is.
func (s *Server) ingestTraffic(w http.ResponseWriter, r *http.Request) {
tr := s.m.Traffic()
if tr == nil {
writeErr(w, 503, "traffic store is not available")
return
}
var req struct {
Exchanges []traffic.ExternalExchange `json:"exchanges"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if len(req.Exchanges) == 0 {
writeErr(w, 400, "empty exchanges")
return
}
if len(req.Exchanges) > maxIngestBatch {
writeErr(w, 400, "too many exchanges in one batch")
return
}
ingested, failed := 0, 0
for _, e := range req.Exchanges {
e.RequestBody = truncateBody(e.RequestBody)
e.ResponseBody = truncateBody(e.ResponseBody)
if err := tr.RecordExternal(e); err != nil {
failed++
continue
}
ingested++
}
writeJSON(w, 200, map[string]any{"ingested": ingested, "failed": failed})
}
func truncateBody(s string) string {
if len(s) <= maxIngestBodySize {
return s
}
return s[:maxIngestBodySize] + "\n... [truncated by ingest]"
}