Files
ARTEX/traffic/external.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

125 lines
4.4 KiB
Go

package traffic
import (
"fmt"
"log"
"net/url"
"strings"
"time"
"github.com/Autumn-27/artex/db"
)
// ExternalExchange is one captured request/response pair handed in from
// outside the MITM proxy (currently the browser-extension ingest endpoint).
// Header maps are lowercased-name oriented on the extension side but any
// casing is accepted; bodies are raw text (binary capture is skipped by the
// extension, not handled here).
type ExternalExchange struct {
Method string `json:"method"`
URL string `json:"url"`
Status int `json:"status"`
RequestHeaders map[string]string `json:"requestHeaders"`
RequestBody string `json:"requestBody"`
ResponseHeaders map[string]string `json:"responseHeaders"`
ResponseBody string `json:"responseBody"`
}
// RecordExternal stores an externally captured exchange through the same
// spill/blob/index path as proxy traffic, so ingested entries are
// indistinguishable from proxy-captured ones on the Traffic page and in
// full-text search.
func (t *Traffic) RecordExternal(e ExternalExchange) error {
u, err := url.Parse(e.URL)
if err != nil || u.Host == "" {
return fmt.Errorf("invalid url %q", e.URL)
}
method := strings.ToUpper(strings.TrimSpace(e.Method))
if method == "" {
return fmt.Errorf("empty method for %q", e.URL)
}
status := e.Status
if status == 0 {
status = 200
}
host := u.Hostname()
reqHead := fmt.Sprintf("%s %s %s\n%s", method, u.RequestURI(), "HTTP/1.1", headerLines(toHeaderMap(e.RequestHeaders)))
respHead := fmt.Sprintf("HTTP %d\n%s", status, headerLines(toHeaderMap(e.ResponseHeaders)))
respCT := e.ResponseHeaders["content-type"]
t.wmu.Lock()
defer t.wmu.Unlock()
// Bodies are spilled before the transaction opens, matching record().
reqB := t.spill([]byte(e.RequestBody), e.RequestHeaders["content-type"])
respB := t.spill([]byte(e.ResponseBody), respCT)
n := t.seq.Add(1)
now := time.Now()
id := fmt.Sprintf("%d-%04d", now.Unix(), n%10000)
if err := t.insertExchange(id, now.Unix(), host, method, db.TemplatePath(u.EscapedPath()), u.String(), status, respCT, len(e.RequestBody), len(e.ResponseBody), reqHead, reqB, respHead, respB); err != nil {
log.Printf("[traffic] failed to ingest %s: %v", u.String(), err)
return err
}
return nil
}
// insertExchange writes the index row, the body row, blob references and the
// FTS entry inside one transaction. record() and RecordExternal share it so
// both capture paths stay byte-identical in storage layout.
func (t *Traffic) insertExchange(id string, ts int64, host, method, urlTemplate, rawURL string, status int, contentType string, reqLen, respLen int, reqHead string, reqB storedBody, respHead string, respB storedBody) error {
tx, err := t.db.Begin()
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
res, err := tx.Exec(`INSERT OR REPLACE INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
VALUES(?,?,?,?,?,?,?,?,?,?,'')`,
id, ts, host, method, urlTemplate, rawURL, status, contentType, reqLen, respLen)
if err != nil {
return fmt.Errorf("writing index: %w", err)
}
rowid, err := res.LastInsertId()
if err != nil {
return fmt.Errorf("fetching rowid: %w", err)
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO exchange_bodies(id,req_head,req_body,req_blob,resp_head,resp_body,resp_blob)
VALUES(?,?,?,?,?,?,?)`,
id, reqHead, reqB.inline, nullIfEmpty(reqB.hash), respHead, respB.inline, nullIfEmpty(respB.hash)); err != nil {
return fmt.Errorf("writing body: %w", err)
}
for _, h := range []string{reqB.hash, respB.hash} {
if h == "" {
continue
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO blob_refs(hash,exchange_id) VALUES(?,?)`, h, id); err != nil {
return fmt.Errorf("registering blob reference: %w", err)
}
}
if t.fts {
idx := strings.Join([]string{rawURL, reqHead, reqB.index, respHead, respB.index}, "\n")
if _, err := tx.Exec(`INSERT INTO ex_fts(rowid,content) VALUES(?,?)`, rowid, idx); err != nil {
return fmt.Errorf("writing full-text index: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing: %w", err)
}
return nil
}
func toHeaderMap(flat map[string]string) map[string][]string {
m := make(map[string][]string, len(flat))
for k, v := range flat {
m[k] = []string{v}
}
return m
}