Files
ARTEX/traffic/traffic.go
T

1383 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package traffic implements the request-recording subsystem (docs §10): an
// embedded go-mitmproxy proxy whose addon writes every target HTTP exchange into
// a human-browsable file tree, with a sidecar SQLite index for paged queries.
// Full capture, plaintext (no redaction), target HTTP only.
package traffic
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
_ "modernc.org/sqlite"
)
const indexSchema = `
CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY,
ts INTEGER,
host TEXT,
method TEXT,
url_template TEXT,
url TEXT,
status INTEGER,
content_type TEXT,
req_len INTEGER,
resp_len INTEGER,
path TEXT
);
CREATE INDEX IF NOT EXISTS idx_ex_host ON exchanges(host);
CREATE INDEX IF NOT EXISTS idx_ex_tmpl ON exchanges(host, url_template);
CREATE INDEX IF NOT EXISTS idx_ex_ts ON exchanges(ts);
CREATE TABLE IF NOT EXISTS exchange_bodies (
id TEXT PRIMARY KEY,
req_head TEXT NOT NULL DEFAULT '',
req_body BLOB,
req_blob TEXT,
resp_head TEXT NOT NULL DEFAULT '',
resp_body BLOB,
resp_blob TEXT
);
CREATE TABLE IF NOT EXISTS blob_refs (
hash TEXT NOT NULL,
exchange_id TEXT NOT NULL,
PRIMARY KEY (hash, exchange_id)
);
CREATE INDEX IF NOT EXISTS idx_blob_refs_ex ON blob_refs(exchange_id);
`
// ftsSchema is applied separately from indexSchema: a driver build without FTS5
// must degrade to "no full-text search" rather than take the whole recorder down.
// trigram (not the default unicode61) is required for two reasons this subsystem
// depends on: it matches arbitrary substrings — "ssw0r" finds "P@ssw0rd" — and it
// handles CJK, which unicode61 does not tokenize. Contentless (content='') keeps
// only the index, since the text itself lives in exchange_bodies; contentless_delete
// lets rows be deleted without replaying the original text back in.
const ftsSchema = `CREATE VIRTUAL TABLE IF NOT EXISTS ex_fts USING fts5(
content, tokenize='trigram', content='', contentless_delete=1
);`
const (
// maxInlineBody is the cutoff between a body stored inline (SQLite column) and
// one spilled to the content-addressed blob store.
maxInlineBody = 256 * 1024
// blobPreview is how much of a spilled body stays inline so readers can tell
// what it is (JSON shape, SQL dump header, ZIP magic) without fetching it.
blobPreview = 8 * 1024
// maxIndexBody caps a single body's contribution to the full-text index. Text
// is indexed in full below this; binary never reaches the index at all.
maxIndexBody = 4 * 1024 * 1024
// minTrigram is the shortest term the trigram tokenizer can match; shorter
// queries fall back to metadata LIKE.
minTrigram = 3
// maxBlobRead caps one traffic_blob read so paging through a large body never
// floods the agent's context.
maxBlobRead = 8 * 1024
)
// Traffic runs the recording proxy and owns the file tree + index.
type Traffic struct {
dir string
addr string
db *sql.DB
wmu sync.Mutex // serializes record() vs DeleteHost (incl. blob GC)
seq atomic.Int64
proxy *mproxy.Proxy
// fts reports whether the full-text index is available. False on a driver
// build without FTS5: recording and metadata search still work, body search
// degrades to unsupported rather than erroring.
fts bool
// reaping tracks background reclamation of legacy trees, so shutdown and tests
// can wait for it instead of racing it.
reaping sync.WaitGroup
// pass is the set of hosts whose MITM interception failed for a proxy/protocol
// reason; connections to them are tunneled transparently (fail-open) so the
// request still reaches the target — unrecorded — instead of being killed.
pass sync.Map // hostname(string) -> struct{}
}
// Open initializes the traffic tree, blob store and SQLite index under dir.
func Open(dir, addr string) (*Traffic, error) {
for _, d := range []string{dir, filepath.Join(dir, "_index"), filepath.Join(dir, "_blobs"), filepath.Join(dir, "_ca")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
db, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite"))
if err != nil {
return nil, err
}
for _, p := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000"} {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, err
}
}
if _, err := db.Exec(indexSchema); err != nil {
db.Close()
return nil, err
}
t := &Traffic{dir: dir, addr: addr, db: db}
if _, err := db.Exec(ftsSchema); err != nil {
log.Printf("[traffic] 全文索引不可用,正文搜索将被禁用(元数据搜索不受影响):%v", err)
} else {
t.fts = true
}
p, err := mproxy.NewProxy(&mproxy.Options{
Addr: addr,
SslInsecure: true,
CaRootPath: filepath.Join(dir, "_ca"),
})
if err != nil {
db.Close()
return nil, err
}
// Dial targets DIRECTLY. go-mitmproxy's default upstream uses
// http.ProxyFromEnvironment, so an HTTP_PROXY/HTTPS_PROXY in the environment
// (a VPN/system proxy) would make it forward target requests through that
// external proxy — which can't reach the target → 502. We capture target
// traffic directly, never via the host's proxy.
p.SetUpstreamProxy(func(*http.Request) (*url.URL, error) { return nil, nil })
// Fail-open: MITM every host by default, EXCEPT ones a prior request proved we
// can't intercept without breaking (see maybePassthrough). Those are tunneled
// transparently so the request still reaches the target instead of being killed.
p.SetShouldInterceptRule(func(req *http.Request) bool {
_, tunnel := t.pass.Load(hostOnly(req.Host))
return !tunnel
})
p.AddAddon(&sink{t: t})
t.proxy = p
return t, nil
}
// hostOnly strips an optional :port, so passthrough keys match whether the host
// arrives as "example.com:443" (CONNECT) or "example.com" (request URL).
func hostOnly(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return h
}
return hostport
}
// ProxyAddr returns the address workers should set as HTTP(S)_PROXY.
func (t *Traffic) ProxyAddr() string { return "http://127.0.0.1" + t.addr }
// CACertPath returns the PEM CA cert clients must trust to verify HTTPS through
// the MITM proxy (go-mitmproxy writes it here on first start).
func (t *Traffic) CACertPath() string {
return filepath.Join(t.dir, "_ca", "mitmproxy-ca-cert.pem")
}
// Start runs the proxy (blocking); run in a goroutine.
func (t *Traffic) Start() error { return t.proxy.Start() }
// Close waits for background tree reclamation to finish before closing the
// index, so shutdown never leaves a goroutine unlinking files out from under a
// removed data directory.
func (t *Traffic) Close() error {
t.reaping.Wait()
return t.db.Close()
}
func (t *Traffic) DB() *sql.DB { return t.db }
// sink is the go-mitmproxy addon that records completed exchanges.
type sink struct {
mproxy.BaseAddon
t *Traffic
}
func (s *sink) Response(f *mproxy.Flow) {
if f.Request == nil || f.Response == nil {
return
}
s.t.record(f)
}
// RequestError fires when a request through an established MITM tunnel fails. If
// the failure looks proxy/protocol-caused (h2 quirks, HEAD-with-body, protocol
// errors) — not a plain target-unreachable error — we flag the host for
// transparent passthrough so future requests to it succeed instead of dying.
func (s *sink) RequestError(f *mproxy.Flow, err error) { s.t.maybePassthrough(f, err) }
// maybePassthrough marks a host to be tunneled transparently on the next
// connection, but only for errors the proxy itself caused — a target that is
// simply down/filtered would fail without us too, and must stay MITM'd+recorded.
func (t *Traffic) maybePassthrough(f *mproxy.Flow, err error) {
if err == nil || f == nil || f.Request == nil || f.Request.URL == nil || !proxyCausedErr(err) {
return
}
host := f.Request.URL.Hostname()
if host == "" {
return
}
if _, loaded := t.pass.LoadOrStore(host, struct{}{}); !loaded {
log.Printf("[traffic] 与 %s 的 MITM 出错,改为透传(该 host 后续直连目标、不再记录,但请求照常):%v", host, err)
}
}
// proxyCausedErr reports whether err indicates the interception layer (not the
// target) is at fault — HTTP/2 handling, HEAD-with-body, or a protocol violation.
func proxyCausedErr(err error) bool {
s := strings.ToLower(err.Error())
for _, p := range []string{"head request", "http2", "http/2", "protocol error", "protocol_error", "malformed"} {
if strings.Contains(s, p) {
return true
}
}
return false
}
// record persists one exchange entirely in SQLite: metadata, bodies and the
// full-text index. Nothing is written to a per-request directory — only bodies
// above maxInlineBody spill to the content-addressed blob store.
func (t *Traffic) record(f *mproxy.Flow) {
// The write lock covers blob + index writes, so DeleteHost (and its blob GC)
// can run under the same lock without racing a concurrent record.
t.wmu.Lock()
defer t.wmu.Unlock()
host := f.Request.URL.Hostname()
method := f.Request.Method
tmpl := db.TemplatePath(f.Request.URL.EscapedPath())
n := t.seq.Add(1)
now := time.Now()
id := fmt.Sprintf("%d-%04d", now.Unix(), n%10000)
ct := f.Response.Header.Get("Content-Type")
url := f.Request.URL.String()
reqHead := fmt.Sprintf("%s %s %s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, requestHeaderLines(f.Request))
respHead := fmt.Sprintf("HTTP %d\n%s", f.Response.StatusCode, headerLines(f.Response.Header))
// Bodies are spilled before the transaction opens: blob writes are filesystem
// work and must not sit inside the SQLite write lock.
reqB := t.spill(f.Request.Body, f.Request.Header.Get("Content-Type"))
respB := t.spill(f.Response.Body, ct)
tx, err := t.db.Begin()
if err != nil {
log.Printf("[traffic] 记录 %s 失败(开启事务):%v", url, err)
return
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// path stays empty for db-resident exchanges; a non-empty path marks a legacy
// row whose bodies still live in the old on-disk tree (see Get).
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, now.Unix(), host, method, tmpl, url, f.Response.StatusCode, ct,
len(f.Request.Body), len(f.Response.Body))
if err != nil {
log.Printf("[traffic] 记录 %s 失败(写索引):%v", url, err)
return
}
rowid, err := res.LastInsertId()
if err != nil {
log.Printf("[traffic] 记录 %s 失败(取 rowid):%v", url, err)
return
}
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 {
log.Printf("[traffic] 记录 %s 失败(写正文):%v", url, err)
return
}
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 {
log.Printf("[traffic] 记录 %s 失败(登记 blob 引用):%v", url, err)
return
}
}
if t.fts {
// The index is fed from memory, so a body that spilled to _blobs is still
// fully searchable even though only its preview is stored inline.
idx := strings.Join([]string{url, reqHead, reqB.index, respHead, respB.index}, "\n")
if _, err := tx.Exec(`INSERT INTO ex_fts(rowid,content) VALUES(?,?)`, rowid, idx); err != nil {
log.Printf("[traffic] 记录 %s 失败(写全文索引):%v", url, err)
return
}
}
if err := tx.Commit(); err != nil {
log.Printf("[traffic] 记录 %s 失败(提交):%v", url, err)
}
}
// storedBody is one body after the inline/spill decision: inline is what goes in
// the SQLite column (the whole body, or a preview when spilled), hash names the
// blob when it spilled, and index is the text handed to FTS (empty for binary).
type storedBody struct {
inline []byte
hash string
index string
}
// spill decides where one body lives. Small bodies stay inline. Large ones are
// written to the content-addressed store and keep a readable preview inline so a
// reader can identify them without fetching the blob. Either way, text bodies
// are handed to the full-text index in full (up to maxIndexBody) — indexing is
// independent of where the bytes end up.
func (t *Traffic) spill(body []byte, contentType string) storedBody {
if len(body) == 0 {
return storedBody{}
}
text := !isBinaryBody(contentType, body)
indexText := func() string {
if !text {
return ""
}
return string(clipBytes(body, maxIndexBody))
}
if len(body) <= maxInlineBody {
return storedBody{inline: body, index: indexText()}
}
sum := sha256.Sum256(body)
h := hex.EncodeToString(sum[:])
// One bucket level (256 buckets) is enough to keep any single directory small;
// the store only ever holds bodies above maxInlineBody, deduplicated by hash.
blobDir := filepath.Join(t.dir, "_blobs", "sha256", h[:2])
if err := os.MkdirAll(blobDir, 0o755); err != nil {
log.Printf("[traffic] 创建 blob 目录失败:%v", err)
return storedBody{inline: clipBytes(body, blobPreview), index: indexText()}
}
blobPath := filepath.Join(blobDir, h+".bin")
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
if err := os.WriteFile(blobPath, body, 0o644); err != nil {
log.Printf("[traffic] 写 blob %s 失败:%v", h, err)
return storedBody{inline: clipBytes(body, blobPreview), index: indexText()}
}
}
sb := storedBody{hash: h, index: indexText()}
if text {
sb.inline = []byte(truncateUTF8(body, blobPreview))
} else {
sb.inline = []byte(binaryTag(contentType, body))
}
return sb
}
// binaryTypes are content-type prefixes whose bodies are never worth indexing or
// previewing as text. Anything not listed is treated as text (with a NUL-byte
// check as backstop), so unusual-but-searchable types like application/sql or a
// bare text/* are not silently dropped from the index.
var binaryTypes = []string{
"image/", "audio/", "video/", "font/",
"application/octet-stream", "application/zip", "application/gzip",
"application/x-gzip", "application/x-tar", "application/x-7z-compressed",
"application/x-rar", "application/pdf", "application/x-msdownload",
"application/vnd.android.package-archive", "application/java-archive",
"application/wasm", "application/x-shockwave-flash",
}
func isBinaryBody(contentType string, body []byte) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
for _, p := range binaryTypes {
if strings.HasPrefix(ct, p) {
return true
}
}
// Backstop for mislabeled or absent content types: real text does not carry
// NUL bytes, so a NUL in the head of the body means binary regardless.
return bytes.IndexByte(clipBytes(body, 512), 0) >= 0
}
// binaryTag describes a spilled binary body in one line, including the leading
// magic bytes so a reader can recognize the format without downloading it.
func binaryTag(contentType string, body []byte) string {
ct := strings.TrimSpace(contentType)
if ct == "" {
ct = "application/octet-stream"
}
magic := hex.EncodeToString(clipBytes(body, 4))
return fmt.Sprintf("[binary %s, %d bytes, magic=%s]", ct, len(body), magic)
}
func clipBytes(b []byte, n int) []byte {
if len(b) <= n {
return b
}
return b[:n]
}
// truncateUTF8 cuts b to at most n bytes without splitting a multi-byte rune,
// so a preview never ends in half a CJK character.
func truncateUTF8(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
b = b[:n]
// A rune is at most 4 bytes, so backing off 3 bytes always finds the boundary
// (unless the input was already invalid UTF-8, in which case we keep the cut).
for i := 0; i < utf8.UTFMax-1 && len(b) > 0; i++ {
if r, size := utf8.DecodeLastRune(b); r != utf8.RuneError || size != 1 {
break
}
b = b[:len(b)-1]
}
return string(b)
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func headerLines(h map[string][]string) string {
var b strings.Builder
for k, vs := range h {
for _, v := range vs {
b.WriteString(k)
b.WriteString(": ")
b.WriteString(v)
b.WriteByte('\n')
}
}
return b.String()
}
// requestHeaderLines restores the HTTP Host header, which net/http stores on
// Request.Host rather than in Header. Keeping it in request.http makes the raw
// capture complete and directly replayable.
func requestHeaderLines(req *mproxy.Request) string {
headers := req.Header.Clone()
headers.Del("Host")
host := req.URL.Host
if raw := req.Raw(); raw != nil && strings.TrimSpace(raw.Host) != "" {
host = raw.Host
}
var b strings.Builder
if host = strings.TrimSpace(host); host != "" {
b.WriteString("Host: ")
b.WriteString(host)
b.WriteByte('\n')
}
b.WriteString(headerLines(headers))
return b.String()
}
func sanitize(s string) string {
r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
out := r.Replace(s)
if out == "" || out == "_" {
return "root"
}
if len(out) > 120 {
out = out[:120]
}
return out
}
var blobHashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
// blobPath locates a stored blob. Current writes use one bucket level; blobs
// written before that change used two, so both layouts are probed rather than
// migrated.
func (t *Traffic) blobPath(hash string) (string, error) {
hash = strings.ToLower(strings.TrimSpace(hash))
// Validated as pure hex before touching the filesystem, so a crafted hash can
// never traverse out of the blob directory.
if !blobHashRe.MatchString(hash) {
return "", fmt.Errorf("非法的 blob hash")
}
for _, p := range []string{
filepath.Join(t.dir, "_blobs", "sha256", hash[:2], hash+".bin"),
filepath.Join(t.dir, "_blobs", "sha256", hash[:2], hash[2:4], hash+".bin"),
} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, nil
}
}
return "", fmt.Errorf("blob %s 不存在", hash)
}
// Blob opens a spilled body for streaming; the caller must close the file.
// Streaming matters here: the driver exposes no incremental BLOB API, so keeping
// large bodies on disk is what lets them be served without loading them whole.
func (t *Traffic) Blob(hash string) (*os.File, int64, error) {
p, err := t.blobPath(hash)
if err != nil {
return nil, 0, err
}
f, err := os.Open(p)
if err != nil {
return nil, 0, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, st.Size(), nil
}
// BlobRange reads at most length bytes of a blob from offset, and reports the
// blob's total size. Used by the agent tool to page through a large body without
// pulling all of it into the model's context.
func (t *Traffic) BlobRange(hash string, offset, length int64) (data []byte, total int64, err error) {
f, size, err := t.Blob(hash)
if err != nil {
return nil, 0, err
}
defer f.Close()
if offset < 0 {
offset = 0
}
if offset >= size {
return nil, size, nil
}
if length <= 0 || offset+length > size {
length = size - offset
}
buf := make([]byte, length)
n, err := f.ReadAt(buf, offset)
if err != nil && !errors.Is(err, io.EOF) {
return nil, size, err
}
return buf[:n], size, nil
}
// ExchangeMeta is one row of the index (returned by Search).
type ExchangeMeta struct {
ID string `json:"id"`
TS int64 `json:"ts"`
Host string `json:"host"`
Method string `json:"method"`
URLTemplate string `json:"url_template"`
URL string `json:"url"`
Status int `json:"status"`
ContentType string `json:"content_type"`
RespLen int `json:"resp_len"`
Path string `json:"path"`
}
// Search returns paged exchange metadata (never bodies).
func (t *Traffic) Search(host string, page, size int) ([]ExchangeMeta, error) {
if size <= 0 || size > 500 {
size = 100
}
q := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges`
args := []any{}
if host != "" {
q += ` WHERE host=?`
args = append(args, host)
}
q += ` ORDER BY ts DESC LIMIT ? OFFSET ?`
args = append(args, size, page*size)
rows, err := t.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExchangeMeta
for rows.Next() {
var m ExchangeMeta
if err := rows.Scan(&m.ID, &m.TS, &m.Host, &m.Method, &m.URLTemplate, &m.URL, &m.Status, &m.ContentType, &m.RespLen, &m.Path); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// ftsFilter builds the SQL condition restricting rows to those whose indexed
// text matches term. ok is false when full-text search cannot serve the term —
// no FTS index on this build, or fewer than three characters, which the trigram
// tokenizer cannot match — leaving callers to fall back to metadata matching.
func (t *Traffic) ftsFilter(term string) (cond string, arg any, ok bool) {
term = strings.TrimSpace(term)
if !t.fts || utf8.RuneCountInString(term) < minTrigram {
return "", nil, false
}
return `rowid IN (SELECT rowid FROM ex_fts WHERE ex_fts MATCH ?)`, ftsQuote(term), true
}
// ftsQuote wraps a term as an FTS5 string literal so that punctuation and query
// operators inside it (quotes, AND/OR/NEAR, *, ^) are matched literally instead
// of being parsed as query syntax.
func ftsQuote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// Page returns one page of exchange metadata filtered by an optional host
// substring, exact method, and a free-text query q that matches across the
// indexed metadata columns (host/url/method/content-type/status) and, when the
// term is long enough for the trigram index, across captured request/response
// text as well. Also returns the total number of rows matching that filter (for
// the UI's pagination). Newest first. Bodies are never included in the rows.
func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeMeta, total int, err error) {
if size <= 0 || size > 500 {
size = 100
}
if page < 0 {
page = 0
}
where := ""
var args []any
add := func(cond string, vs ...any) {
if where == "" {
where = " WHERE "
} else {
where += " AND "
}
where += cond
args = append(args, vs...)
}
if h := strings.TrimSpace(host); h != "" {
add("host LIKE ?", "%"+h+"%")
}
if m := strings.TrimSpace(method); m != "" {
add("method=?", strings.ToUpper(m))
}
if s := strings.TrimSpace(q); s != "" {
like := "%" + s + "%"
const meta = "host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
// Metadata match OR full-text match: one search box, widest recall. Terms
// too short for trigram silently fall back to metadata only.
if cond, arg, ok := t.ftsFilter(s); ok {
add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, arg)
} else {
add("("+meta+")", like, like, like, like, like, like)
}
}
if err = t.db.QueryRow(`SELECT COUNT(*) FROM exchanges`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
sel := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges` +
where + ` ORDER BY ts DESC LIMIT ? OFFSET ?`
qargs := append(append([]any{}, args...), size, page*size)
rs, err := t.db.Query(sel, qargs...)
if err != nil {
return nil, 0, err
}
defer rs.Close()
for rs.Next() {
var m ExchangeMeta
if err := rs.Scan(&m.ID, &m.TS, &m.Host, &m.Method, &m.URLTemplate, &m.URL, &m.Status, &m.ContentType, &m.RespLen, &m.Path); err != nil {
return nil, 0, err
}
rows = append(rows, m)
}
return rows, total, rs.Err()
}
// Get returns the full request/response text of one exchange. Bodies come from
// the database; rows recorded before bodies moved into SQLite carry a non-empty
// path and are read from the legacy on-disk tree instead, so history stays
// readable without being migrated.
func (t *Traffic) Get(id string) (req, resp string, err error) {
var reqHead, respHead string
var reqBody, respBody []byte
var reqBlob, respBlob sql.NullString
var reqLen, respLen int
err = t.db.QueryRow(`SELECT b.req_head,b.req_body,b.req_blob,b.resp_head,b.resp_body,b.resp_blob,e.req_len,e.resp_len
FROM exchange_bodies b JOIN exchanges e ON e.id=b.id WHERE b.id=?`, id).
Scan(&reqHead, &reqBody, &reqBlob, &respHead, &respBody, &respBlob, &reqLen, &respLen)
if err == nil {
return assembleRaw(reqHead, reqBody, reqBlob, reqLen), assembleRaw(respHead, respBody, respBlob, respLen), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", err
}
var rel string
if err = t.db.QueryRow(`SELECT path FROM exchanges WHERE id=?`, id).Scan(&rel); err != nil {
return "", "", err
}
if strings.TrimSpace(rel) == "" {
return "", "", fmt.Errorf("exchange %s 无正文记录", id)
}
rb, _ := os.ReadFile(filepath.Join(t.dir, rel, "request.http"))
pb, _ := os.ReadFile(filepath.Join(t.dir, rel, "response.http"))
return string(rb), string(pb), nil
}
// assembleRaw rebuilds one side's raw HTTP text: head, blank line, body. A body
// that spilled to the blob store shows its inline preview followed by the blob
// pointer, so the reader can see what it is and fetch the rest by hash.
func assembleRaw(head string, body []byte, blob sql.NullString, total int) string {
var b strings.Builder
b.WriteString(head)
b.WriteByte('\n')
b.Write(body)
if blob.Valid && blob.String != "" {
fmt.Fprintf(&b, "\n…[truncated] @blob sha256:%s (len=%d)", blob.String, total)
}
return b.String()
}
// HostCount is one distinct recorded host plus its exchange count.
type HostCount struct {
Host string `json:"host"`
Count int `json:"count"`
}
// Hosts returns distinct recorded hosts with exchange counts, most recent
// activity first — powers the page's target picker.
func (t *Traffic) Hosts() ([]HostCount, error) {
rows, err := t.db.Query(`SELECT host, COUNT(*) AS n, MAX(ts) AS last FROM exchanges GROUP BY host ORDER BY last DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HostCount
for rows.Next() {
var h HostCount
var last int64
if err := rows.Scan(&h.Host, &h.Count, &last); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// Count returns total recorded exchanges.
func (t *Traffic) Count() (int, error) {
var n int
err := t.db.QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&n)
return n, err
}
// DeleteHost removes every recorded exchange whose host contains the given
// substring — mirroring the UI's host filter, so what you filtered is what gets
// deleted. Everything lives in SQLite, so the deletion is one transaction across
// the index, bodies, full-text index and blob references. Content-addressed
// blobs are shared across hosts and so are garbage-collected afterwards rather
// than deleted by host. Returns rows deleted.
func (t *Traffic) DeleteHost(host string) (int64, error) {
like := "%" + host + "%"
t.wmu.Lock()
defer t.wmu.Unlock()
// Resolved before the rows go away: with a substring match, the index is the
// only record of which host directories the filter actually hit.
legacy, err := t.hostTrees(`host LIKE ?`, like)
if err != nil {
return 0, err
}
tx, err := t.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
n, err := t.deleteWhere(tx, `host LIKE ?`, like)
if err != nil {
return 0, err
}
// Staged before the commit so a filesystem failure can still abort the whole
// deletion, and before gcBlobs so reference collection sees the correct live
// set — a staged tree is invisible to it.
stageDir, moves, err := t.stageTrees(legacy)
if err != nil {
return 0, errors.Join(err, restoreTrees(stageDir, moves))
}
if err := tx.Commit(); err != nil {
return 0, errors.Join(fmt.Errorf("提交流量索引删除: %w", err), restoreTrees(stageDir, moves))
}
t.reapStage(stageDir)
if n > 0 {
if err := t.gcBlobs(); err != nil {
return n, err
}
}
return n, nil
}
// deleteWhere removes every trace of the exchanges matching the condition:
// full-text index rows, bodies, blob references, and finally the index rows.
// Order matters — each sub-select reads exchanges, so that table is emptied last.
func (t *Traffic) deleteWhere(tx *sql.Tx, where string, args ...any) (int64, error) {
if t.fts {
// ex_fts is contentless and addressed by rowid, hence the rowid sub-select.
if _, err := tx.Exec(`DELETE FROM ex_fts WHERE rowid IN (SELECT rowid FROM exchanges WHERE `+where+`)`, args...); err != nil {
return 0, fmt.Errorf("删除全文索引: %w", err)
}
}
if _, err := tx.Exec(`DELETE FROM exchange_bodies WHERE id IN (SELECT id FROM exchanges WHERE `+where+`)`, args...); err != nil {
return 0, fmt.Errorf("删除正文: %w", err)
}
if _, err := tx.Exec(`DELETE FROM blob_refs WHERE exchange_id IN (SELECT id FROM exchanges WHERE `+where+`)`, args...); err != nil {
return 0, fmt.Errorf("删除 blob 引用: %w", err)
}
res, err := tx.Exec(`DELETE FROM exchanges WHERE `+where, args...)
if err != nil {
return 0, fmt.Errorf("删除索引行: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// hostTrees returns the on-disk directory of every host matching the condition.
// Exchanges recorded since bodies moved into SQLite have no directory at all, so
// most of these paths simply will not exist — staging skips those. The lookup is
// deliberately not restricted to rows with a path: a host whose index rows are
// already gone can still have an orphaned directory, and deleting the host should
// take that with it.
func (t *Traffic) hostTrees(where string, args ...any) ([]string, error) {
rows, err := t.db.Query(`SELECT DISTINCT host FROM exchanges WHERE `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var dirs []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
dirs = append(dirs, filepath.Join(t.dir, sanitize(h)))
}
return dirs, rows.Err()
}
type stagedTrafficPath struct {
source string
staged string
}
// stageTrees moves host directories aside onto the same filesystem. The rename is
// atomic and instant, which buys two things the unlink cannot: the deletion stays
// reversible until the transaction commits, and the tree stops being visible to
// blob reference collection right away (legacyBlobRefs skips underscore-prefixed
// directories, so anything under _delete_staging is already out of the live set).
// An empty stageDir is returned when there was nothing to stage.
func (t *Traffic) stageTrees(dirs []string) (stageDir string, moves []stagedTrafficPath, err error) {
seen := make(map[string]struct{}, len(dirs))
for _, source := range dirs {
if _, dup := seen[source]; dup {
continue
}
seen[source] = struct{}{}
if _, err := os.Lstat(source); err != nil {
if os.IsNotExist(err) {
continue
}
return stageDir, moves, fmt.Errorf("检查历史流量目录 %s: %w", source, err)
}
if stageDir == "" {
parent := filepath.Join(t.dir, "_delete_staging")
if err := os.MkdirAll(parent, 0o700); err != nil {
return stageDir, moves, fmt.Errorf("创建流量暂存目录: %w", err)
}
if stageDir, err = os.MkdirTemp(parent, "hosts-"); err != nil {
return stageDir, moves, fmt.Errorf("创建流量暂存目录: %w", err)
}
}
staged := filepath.Join(stageDir, fmt.Sprintf("%d-%s", len(moves), filepath.Base(source)))
if err := os.Rename(source, staged); err != nil {
return stageDir, moves, fmt.Errorf("移出历史流量目录 %s: %w", source, err)
}
moves = append(moves, stagedTrafficPath{source: source, staged: staged})
}
return stageDir, moves, nil
}
// restoreTrees puts staged directories back where they came from, newest move
// first, and drops the staging directory once everything is home.
func restoreTrees(stageDir string, moves []stagedTrafficPath) error {
var errs []error
for i := len(moves) - 1; i >= 0; i-- {
move := moves[i]
if _, err := os.Lstat(move.source); err == nil {
errs = append(errs, fmt.Errorf("restore destination already exists: %s", move.source))
continue
} else if !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("inspect restore destination %s: %w", move.source, err))
continue
}
if err := os.Rename(move.staged, move.source); err != nil {
errs = append(errs, fmt.Errorf("restore %s: %w", move.source, err))
}
}
if len(errs) == 0 && stageDir != "" {
if err := os.RemoveAll(stageDir); err != nil {
errs = append(errs, fmt.Errorf("remove traffic stage: %w", err))
}
}
return errors.Join(errs...)
}
// reapStage unlinks a committed staging directory in the background. This is the
// step that used to stall the recorder: a legacy tree mirrors the URL path per
// request and can hold hundreds of thousands of small files, and the whole unlink
// ran while the write lock was held. The rows are already gone by now, so losing
// this to a shutdown leaves garbage under _delete_staging, never inconsistent
// state.
func (t *Traffic) reapStage(stageDir string) {
if stageDir == "" {
return
}
t.reaping.Go(func() {
if err := os.RemoveAll(stageDir); err != nil {
log.Printf("[traffic] 清理历史流量目录 %s 失败:%v", stageDir, err)
}
})
}
// DeleteHostsExact removes recorded exchanges for a set of EXACT hosts (the
// batch path for the page's multi-select delete): index rows + each host's file
// tree, then one blob-GC pass. Exact match — unlike DeleteHost's substring —
// so picking "api.example.com" never sweeps "api.example.com.cn". Returns rows
// deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass).
func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) {
stage, err := t.StageDeleteHostsExact(hosts)
if err != nil {
return 0, err
}
deleted := stage.Deleted()
if err := stage.Commit(); err != nil {
return deleted, err
}
return deleted, nil
}
// HostDeleteStage keeps the SQLite delete transaction open so a task deletion
// can be coordinated with PostgreSQL: the traffic side is staged here and only
// committed once the caller's own transaction has succeeded. The Traffic write
// lock is held until Commit or Rollback, so no recorder can add rows or a blob
// reference for a host that is mid-deletion. Rolling back is just a transaction
// rollback now that nothing is moved on disk.
type HostDeleteStage struct {
traffic *Traffic
tx *sql.Tx
stageDir string
moves []stagedTrafficPath
deleted int64
done bool
}
// Deleted reports the number of exchange rows selected by this stage.
func (s *HostDeleteStage) Deleted() int64 {
if s == nil {
return 0
}
return s.deleted
}
// StageDeleteHostsExact prepares a reversible exact-host deletion. Callers must
// finish every successful stage with Commit or Rollback.
func (t *Traffic) StageDeleteHostsExact(hosts []string) (*HostDeleteStage, error) {
t.wmu.Lock()
stage := &HostDeleteStage{traffic: t}
fail := func(cause error) (*HostDeleteStage, error) {
if rollbackErr := stage.rollbackLocked(); rollbackErr != nil {
return nil, errors.Join(cause, fmt.Errorf("回滚流量删除: %w", rollbackErr))
}
return nil, cause
}
abort := func(cause error) (*HostDeleteStage, error) {
t.wmu.Unlock()
stage.done = true
return nil, cause
}
unique := make([]string, 0, len(hosts))
seen := make(map[string]struct{}, len(hosts))
for _, host := range hosts {
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
unique = append(unique, host)
}
// Exact hosts are known up front, so their directories are derived directly
// rather than looked up: a host whose index rows are already gone can still
// own an orphaned directory that this deletion should take with it.
legacy := make([]string, 0, len(unique))
for _, h := range unique {
legacy = append(legacy, filepath.Join(t.dir, sanitize(h)))
}
tx, err := t.db.Begin()
if err != nil {
return abort(err)
}
stage.tx = tx
for _, h := range unique {
n, err := t.deleteWhere(tx, `host=?`, h)
if err != nil {
return fail(err)
}
stage.deleted += n
}
stage.stageDir, stage.moves, err = t.stageTrees(legacy)
if err != nil {
return fail(err)
}
return stage, nil
}
// Rollback discards the staged deletion, leaving the traffic store untouched.
func (s *HostDeleteStage) Rollback() error {
if s == nil || s.done {
return nil
}
return s.rollbackLocked()
}
func (s *HostDeleteStage) rollbackLocked() error {
if s == nil || s.done {
return nil
}
var errs []error
if s.tx != nil {
if err := s.tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
errs = append(errs, fmt.Errorf("回滚流量索引: %w", err))
}
}
if err := restoreTrees(s.stageDir, s.moves); err != nil {
errs = append(errs, err)
}
s.done = true
s.traffic.wmu.Unlock()
return errors.Join(errs...)
}
// Commit makes the staged deletion permanent. SQLite is committed only after
// the caller has committed its PostgreSQL task deletion.
func (s *HostDeleteStage) Commit() error {
if s == nil || s.done {
return nil
}
if err := s.tx.Commit(); err != nil {
// A failed SQLite commit normally rolls the transaction back. Restore the
// trees so the traffic store remains internally consistent and recoverable.
restoreErr := restoreTrees(s.stageDir, s.moves)
s.done = true
s.traffic.wmu.Unlock()
return errors.Join(fmt.Errorf("提交流量索引删除: %w", err), restoreErr)
}
// Unlinking the staged trees is what used to hold the write lock for hours;
// it now runs in the background, while collection below only needs them to be
// out of the live tree — which the staging rename already guaranteed.
s.traffic.reapStage(s.stageDir)
var errs []error
if err := s.traffic.gcBlobs(); err != nil {
errs = append(errs, fmt.Errorf("回收流量 blob: %w", err))
}
s.done = true
s.traffic.wmu.Unlock()
return errors.Join(errs...)
}
// gcBlobs removes blobs that no remaining exchange references. Live references
// come from blob_refs, so collection is one query plus a sweep of the blob
// directory — no exchange body is ever read. Emptied bucket directories are
// removed as well; the previous file-scanning collector deleted only files and
// left the buckets behind permanently. Best-effort: walk errors only skip the
// affected entries. Callers must hold wmu so this never races a concurrent
// record() writing a fresh blob + reference.
func (t *Traffic) gcBlobs() error {
refs := make(map[string]struct{})
rows, err := t.db.Query(`SELECT DISTINCT hash FROM blob_refs`)
if err != nil {
return err
}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
rows.Close()
return err
}
refs[h] = struct{}{}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if err := t.legacyBlobRefs(refs); err != nil {
return err
}
root := filepath.Join(t.dir, "_blobs", "sha256")
var buckets []string
if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil || d == nil {
return nil
}
if d.IsDir() {
if p != root {
buckets = append(buckets, p)
}
return nil
}
h := strings.TrimSuffix(d.Name(), ".bin")
if _, ok := refs[h]; !ok {
os.Remove(p)
}
return nil
}); err != nil {
return err
}
// Deepest first, so a two-level bucket left by the old layout collapses fully.
// Remove fails harmlessly on a non-empty directory — exactly the guard needed
// to avoid deleting a bucket that still holds live blobs.
sort.Slice(buckets, func(i, j int) bool { return len(buckets[i]) > len(buckets[j]) })
for _, b := range buckets {
os.Remove(b)
}
return nil
}
// legacyBlobRefs adds hashes referenced by pre-SQLite exchanges, whose bodies are
// still .http files on disk holding "@blob sha256:<hex>" pointers. Without this
// the first collection after the upgrade would delete blobs that history still
// points at. Skipped entirely once no legacy rows remain — the steady state — so
// the tree walk is transitional rather than a permanent cost.
func (t *Traffic) legacyBlobRefs(refs map[string]struct{}) error {
var n int
if err := t.db.QueryRow(`SELECT COUNT(*) FROM exchanges WHERE path<>''`).Scan(&n); err != nil {
return err
}
if n == 0 {
return nil
}
blobRe := regexp.MustCompile(`@blob sha256:([0-9a-f]{64})`)
return filepath.WalkDir(t.dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d == nil {
return nil // skip unreadable entries
}
if d.IsDir() {
// _blobs/_index/_ca never contain references; skip their subtrees.
if p != t.dir && strings.HasPrefix(d.Name(), "_") {
return filepath.SkipDir
}
return nil
}
if nm := d.Name(); nm != "request.http" && nm != "response.http" {
return nil
}
b, err := os.ReadFile(p)
if err != nil {
return nil
}
for _, m := range blobRe.FindAllSubmatch(b, -1) {
refs[string(m[1])] = struct{}{}
}
return nil
})
}
// query returns one page of exchange metadata filtered by host and/or a url
// substring. Default page size is intentionally small (3) to keep tool results
// lightweight and capped at 10; page is 0-based (page*limit offset).
func (t *Traffic) query(host, contains, bodyContains string, page, limit int) ([]ExchangeMeta, error) {
if limit <= 0 {
limit = 3
}
if limit > 10 {
limit = 10
}
if page < 0 {
page = 0
}
q := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges WHERE 1=1`
args := []any{}
if host != "" {
q += ` AND host=?`
args = append(args, host)
}
if contains != "" {
q += ` AND (url LIKE ? OR url_template LIKE ?)`
args = append(args, "%"+contains+"%", "%"+contains+"%")
}
if b := strings.TrimSpace(bodyContains); b != "" {
cond, arg, ok := t.ftsFilter(b)
if !ok {
if !t.fts {
return nil, fmt.Errorf("当前实例未启用全文索引,无法按正文搜索")
}
return nil, fmt.Errorf("正文搜索关键词至少需要 %d 个字符(当前 %d 个)", minTrigram, utf8.RuneCountInString(b))
}
q += ` AND ` + cond
args = append(args, arg)
}
q += ` ORDER BY ts DESC LIMIT ? OFFSET ?`
args = append(args, limit, page*limit)
rows, err := t.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExchangeMeta
for rows.Next() {
var m ExchangeMeta
if err := rows.Scan(&m.ID, &m.TS, &m.Host, &m.Method, &m.URLTemplate, &m.URL, &m.Status, &m.ContentType, &m.RespLen, &m.Path); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Tools exposes traffic lookup to work agents so they query already-captured
// traffic instead of re-curling the same resource (token + dedup win).
func (t *Traffic) Tools() []actool.CoreTool {
allow := func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.Allowed()
}
ro := func(json.RawMessage) bool { return true }
search := actool.Build(actool.Spec{
Name: "traffic_search",
Description: "查询记录代理已抓取的目标流量(必须指定 host,可再按 URL 子串或正文关键词过滤)。body_contains 会在已抓取的请求/响应头与正文中做全文搜索,支持任意子串和中文(至少 3 个字符),可用来找响应里的密码、密钥、报错、内网地址等。仅返回极轻量索引(id/method/url/status/resp_len),不含任何响应内容。默认只返回 3 条、每页最多 10 条;结果多时用 page 翻页(page=0 起);要看某条的请求/响应原文用 traffic_get(id)。回看已访问资源、找端点先用它,避免重复 curl 同一 URL。",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"host": map[string]any{"type": "string", "description": "按主机过滤(必填,如 '107.172.96.177:8082')"},
"contains": map[string]any{"type": "string", "description": "URL 子串过滤(可选,如 'api' / 'login')"},
"body_contains": map[string]any{"type": "string", "description": "正文全文搜索(可选,至少 3 个字符),匹配请求/响应的头与正文,如 'password' / 'root:x:0' / '内网测试'"},
"limit": map[string]any{"type": "integer", "description": "每页条数,默认 3,最大 10"},
"page": map[string]any{"type": "integer", "description": "页码,从 0 开始,默认 0(按 ts 倒序分页)"},
},
"required": []any{"host"},
},
ReadOnly: ro,
Permissions: allow,
Run: func(_ context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
var a struct {
Host, Contains string
BodyContains string `json:"body_contains"`
Limit int
Page int
}
_ = json.Unmarshal(in, &a)
if strings.TrimSpace(a.Host) == "" {
return actool.Errorf("host 为必填参数:请指定要查询的主机(如 '107.172.96.177:8082'),避免全库扫描。"), nil
}
rows, err := t.query(a.Host, a.Contains, a.BodyContains, a.Page, a.Limit)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if len(rows) == 0 {
return actool.Text("无匹配流量。"), nil
}
// 精简为最小索引:仅保留定位所需字段 + 响应码/长度,不带任何响应内容。
type liteRow struct {
ID string `json:"id"`
Method string `json:"method"`
URL string `json:"url"`
Status int `json:"status"`
RespLen int `json:"resp_len"`
}
lite := make([]liteRow, 0, len(rows))
for _, r := range rows {
lite = append(lite, liteRow{ID: r.ID, Method: r.Method, URL: r.URL, Status: r.Status, RespLen: r.RespLen})
}
b, _ := json.Marshal(lite)
return actool.Text(string(b)), nil
},
})
get := actool.Build(actool.Spec{
Name: "traffic_get",
Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。配合 traffic_search 用,避免重复 curl。",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{"id": map[string]any{"type": "string", "description": "traffic_search 返回的 id"}},
"required": []any{"id"},
},
ReadOnly: ro,
Permissions: allow,
Run: func(_ context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
var a struct{ ID string }
_ = json.Unmarshal(in, &a)
req, resp, err := t.Get(a.ID)
if err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text("=== REQUEST ===\n" + clip(req, 2500) + "\n\n=== RESPONSE ===\n" + clip(resp, 4000)), nil
},
})
blob := actool.Build(actool.Spec{
Name: "traffic_blob",
Description: "分段读取超大请求/响应体的原文。traffic_get 里显示为 '…[truncated] @blob sha256:<hash>' 的部分即存放于此,把该 hash 传进来即可取完整内容。单次最多返回 8KB,用 offset 继续往后读(返回结果会给出总长度)。适合翻阅备份文件、源码泄露、大 JSON 导出等超过内联阈值的响应。",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"hash": map[string]any{"type": "string", "description": "traffic_get 中 @blob sha256: 后面的 64 位十六进制值"},
"offset": map[string]any{"type": "integer", "description": "起始字节偏移,默认 0"},
"length": map[string]any{"type": "integer", "description": "本次读取字节数,默认且最大 8192"},
},
"required": []any{"hash"},
},
ReadOnly: ro,
Permissions: allow,
Run: func(_ context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
var a struct {
Hash string
Offset int64
Length int64
}
_ = json.Unmarshal(in, &a)
if a.Length <= 0 || a.Length > maxBlobRead {
a.Length = maxBlobRead
}
data, total, err := t.BlobRange(a.Hash, a.Offset, a.Length)
if err != nil {
return actool.Errorf(err.Error()), nil
}
if len(data) == 0 {
return actool.Text(fmt.Sprintf("偏移 %d 已超出内容长度(总长 %d 字节)。", a.Offset, total)), nil
}
head := fmt.Sprintf("[offset=%d 本次=%d 总长=%d]\n", a.Offset, len(data), total)
if isBinaryBody("", data) {
return actool.Text(head + "二进制内容,以十六进制展示前 512 字节:\n" + hex.EncodeToString(clipBytes(data, 512))), nil
}
return actool.Text(head + truncateUTF8(data, len(data))), nil
},
})
return []actool.CoreTool{search, get, blob}
}
// SeedToolMetas returns the traffic tools built on a ZERO receiver, for seeding the
// tools catalog (metadata only — Name/Description/InputSchema). The handlers close
// over the nil receiver but are never invoked on this instance, so it is safe.
func SeedToolMetas() []actool.CoreTool { return (&Traffic{}).Tools() }
func clip(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("\n... [截断,共 %d 字节;完整在流量文件树] ...", len(s))
}