mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.
Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.
Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):
pkg/client:
- websocket.go:42 DefaultLogger.Printf now pre-formats and sanitises
the entire message (all variadic args sanitised)
- websocket.go:445 err → sanitizeErr(err)
pkg/service/handlers:
- handlers_account_mgmt.go:44 err
- handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
- handlers_marge.go:288,510 err (deviceID/account already done)
- handlers_mgmt.go:409,436,720 err
- handlers_setup.go:1345 session + err
- server.go:500 bind
- server.go:504,863,944,1029, err (deviceIP/accountID already done)
1164,1174
pkg/service/marge:
- marge.go:1469,1923 saveErr / err
pkg/service/setup:
- setup.go:1417,2316,2462 fmt.Printf — deviceIP / hostsContent / ip
pkg/service/stockholm:
- proxy.go:117 effectiveTarget.String() + err
pkg/service/zeroconf:
- zeroconf.go:312 err
pkg/service/proxy:
- recorder.go:403 err (task.path already sanitised)
pkg/service/datastore:
- datastore.go:940 werr (device already sanitised)
pkg/discovery:
- dns.go:72 strings.Join(derived)
- dns.go:503 d.upstreamDNS (fmt.Sprint of []string)
cmd/soundtouch-cli:
- cmd_events.go:571 VerboseLogger.Printf — pre-format + sanitise
- common.go:335 PrintError message
cmd/websocket-demo:
- main.go:576 VerboseLogger.Printf — pre-format + sanitise
examples:
- recording-filename-demo.go:79 err
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1087 lines
26 KiB
Go
1087 lines
26 KiB
Go
package proxy
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Recorder handles persisting HTTP interactions as .http files.
|
|
type Recorder struct {
|
|
BaseDir string
|
|
SessionID string
|
|
SessionDir string
|
|
Patterns PathPatterns
|
|
Redact bool
|
|
counter uint64
|
|
variables map[string]string
|
|
mu sync.Mutex
|
|
queue chan recordingTask
|
|
|
|
// rootMu guards lazy initialisation of root.
|
|
rootMu sync.Mutex
|
|
// root is an os.Root anchored at BaseDir; all filesystem operations
|
|
// that take a caller-derivable path go through it so the Go runtime
|
|
// guarantees containment regardless of what the path string contains.
|
|
root *os.Root
|
|
}
|
|
|
|
type recordingTask struct {
|
|
category string
|
|
req *http.Request
|
|
res *http.Response
|
|
replacements map[string]string
|
|
dir string
|
|
path string
|
|
}
|
|
|
|
// InteractionStats represents statistics for recorded interactions.
|
|
type InteractionStats struct {
|
|
TotalRequests int `json:"total_requests"`
|
|
ByService map[string]int `json:"by_service"`
|
|
BySession map[string]int `json:"by_session"`
|
|
}
|
|
|
|
// Interaction represents a single recorded HTTP interaction.
|
|
type Interaction struct {
|
|
ID string `json:"id"`
|
|
Session string `json:"session"`
|
|
Category string `json:"category"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
File string `json:"file"`
|
|
Counter int `json:"counter"`
|
|
Status int `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
SCMUDCData *EnrichedSCMUDCEvent `json:"scmudc_data,omitempty"`
|
|
}
|
|
|
|
// NewRecorder creates a new HTTP interaction recorder.
|
|
func NewRecorder(baseDir string) *Recorder {
|
|
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
|
|
|
|
r := &Recorder{
|
|
BaseDir: baseDir,
|
|
SessionID: sessionID,
|
|
Patterns: DefaultPatterns(),
|
|
variables: make(map[string]string),
|
|
}
|
|
|
|
// Use environment variable to control async recording, default to true for production
|
|
// but allow disabling it for tests if needed.
|
|
if os.Getenv("RECORDER_ASYNC") != "false" {
|
|
r.queue = make(chan recordingTask, 100)
|
|
go r.worker()
|
|
} else {
|
|
log.Println("Recorder starting in synchronous mode")
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// Close stops the recorder and waits for pending tasks to finish.
|
|
func (r *Recorder) Close() {
|
|
if r.queue != nil {
|
|
close(r.queue)
|
|
// We might want to wait here, but for now just closing is a start
|
|
}
|
|
|
|
r.rootMu.Lock()
|
|
defer r.rootMu.Unlock()
|
|
|
|
if r.root != nil {
|
|
_ = r.root.Close()
|
|
r.root = nil
|
|
}
|
|
}
|
|
|
|
// getRoot lazily opens the *os.Root anchored at r.BaseDir. The directory is
|
|
// MkdirAll-created on first call.
|
|
func (r *Recorder) getRoot() (*os.Root, error) {
|
|
r.rootMu.Lock()
|
|
defer r.rootMu.Unlock()
|
|
|
|
if r.root != nil {
|
|
return r.root, nil
|
|
}
|
|
|
|
if r.BaseDir == "" {
|
|
return nil, fmt.Errorf("recorder: BaseDir not configured")
|
|
}
|
|
|
|
if err := os.MkdirAll(r.BaseDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("recorder: ensure BaseDir %s: %w", r.BaseDir, err)
|
|
}
|
|
|
|
root, err := os.OpenRoot(r.BaseDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recorder: open root at %s: %w", r.BaseDir, err)
|
|
}
|
|
|
|
r.root = root
|
|
|
|
return root, nil
|
|
}
|
|
|
|
// rootRel converts an absolute path under r.BaseDir to its root-relative form.
|
|
func (r *Recorder) rootRel(absPath string) (string, error) {
|
|
if !filepath.IsAbs(absPath) {
|
|
a, err := filepath.Abs(absPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
absPath = a
|
|
}
|
|
|
|
if absPath == r.BaseDir {
|
|
return ".", nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(r.BaseDir, absPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("recorder: %s outside BaseDir: %w", absPath, err)
|
|
}
|
|
|
|
if rel == "." || rel == "" {
|
|
return ".", nil
|
|
}
|
|
|
|
if strings.HasPrefix(rel, "..") {
|
|
return "", fmt.Errorf("recorder: %s outside BaseDir", absPath)
|
|
}
|
|
|
|
return rel, nil
|
|
}
|
|
|
|
func (r *Recorder) rootMkdirAll(absPath string, perm os.FileMode) error {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
|
|
return root.MkdirAll(rel, perm)
|
|
}
|
|
|
|
func (r *Recorder) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return root.WriteFile(rel, data, perm)
|
|
}
|
|
|
|
func (r *Recorder) rootReadFile(absPath string) ([]byte, error) {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return root.ReadFile(rel)
|
|
}
|
|
|
|
func (r *Recorder) rootStat(absPath string) (os.FileInfo, error) {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return root.Stat(rel)
|
|
}
|
|
|
|
func (r *Recorder) rootRemoveAll(absPath string) error {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return root.RemoveAll(rel)
|
|
}
|
|
|
|
func (r *Recorder) rootReadDir(absPath string) ([]os.DirEntry, error) {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d, err := root.Open(rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = d.Close() }()
|
|
|
|
// *os.File.ReadDir(-1) returns directory order; os.ReadDir sorts by
|
|
// name. Match the sorted contract so callers don't see a surprise.
|
|
entries, err := d.ReadDir(-1)
|
|
if err != nil {
|
|
return entries, err
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func (r *Recorder) rootOpen(absPath string) (*os.File, error) {
|
|
root, err := r.getRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rel, err := r.rootRel(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return root.Open(rel)
|
|
}
|
|
|
|
// Record logs an interaction to the configured category.
|
|
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
|
|
if r.BaseDir == "" {
|
|
return nil
|
|
}
|
|
|
|
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
|
|
|
|
dir, err := r.getRecordingDir(category, sanitizedSegments)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := r.rootMkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
}
|
|
|
|
path := r.getRecordingPath(dir, req.Method)
|
|
|
|
// If we are in async mode, we MUST copy the bodies now because the caller
|
|
// might close them as soon as Record() returns.
|
|
var (
|
|
clonedReq *http.Request
|
|
clonedRes *http.Response
|
|
)
|
|
|
|
if r.queue != nil {
|
|
// Clone request
|
|
clonedReq = req.Clone(req.Context())
|
|
if req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read request body for async recording: %v", err)
|
|
|
|
clonedReq.Body = http.NoBody
|
|
} else {
|
|
// Reset original body for subsequent consumers (though Record is usually called at the end)
|
|
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
// Set body for async task
|
|
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedReq.ContentLength = int64(len(bodyBytes))
|
|
}
|
|
}
|
|
|
|
// Clone response if present
|
|
if res != nil {
|
|
clonedRes = &http.Response{
|
|
StatusCode: res.StatusCode,
|
|
Header: res.Header.Clone(),
|
|
Request: clonedReq,
|
|
}
|
|
if res.Body != nil {
|
|
bodyBytes, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read response body for async recording: %v", err)
|
|
|
|
res.Body = http.NoBody
|
|
clonedRes.Body = http.NoBody
|
|
} else {
|
|
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedRes.ContentLength = int64(len(bodyBytes))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
clonedReq = req
|
|
clonedRes = res
|
|
}
|
|
|
|
task := recordingTask{
|
|
category: category,
|
|
req: clonedReq,
|
|
res: clonedRes,
|
|
replacements: replacements,
|
|
dir: dir,
|
|
path: path,
|
|
}
|
|
|
|
// For testing purposes or if queue is nil, fallback to synchronous
|
|
if r.queue == nil {
|
|
r.save(task)
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case r.queue <- task:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("recording queue full, dropping interaction for %s", req.URL.Path)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) save(task recordingTask) {
|
|
var (
|
|
buf bytes.Buffer
|
|
enriched *EnrichedSCMUDCEvent
|
|
)
|
|
|
|
// Check if this is a SCMUDC request and enrich it
|
|
|
|
if strings.Contains(task.req.URL.Path, "/v1/scmudc/") && task.req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(task.req.Body)
|
|
if err == nil {
|
|
task.req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
enriched = enrichSCMUDCRequest(bodyBytes)
|
|
}
|
|
}
|
|
|
|
r.writeRequestWithEnrichment(&buf, task.req, task.replacements, enriched)
|
|
|
|
if task.res != nil {
|
|
r.writeResponseWithEnrichment(&buf, task.res, enriched)
|
|
}
|
|
|
|
if err := r.rootWriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
|
log.Printf("failed to write recording to %s: %s", sanitizeLog(task.path), sanitizeErr(err))
|
|
}
|
|
|
|
_ = r.updateEnvFile(task.replacements)
|
|
}
|
|
|
|
func (r *Recorder) worker() {
|
|
for task := range r.queue {
|
|
r.save(task)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
|
|
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
|
|
sanitizedSegments := make([]string, 0, len(pathSegments))
|
|
replacements := make(map[string]string)
|
|
|
|
for _, segment := range pathSegments {
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
|
|
sanitized, replacement := r.Patterns.Sanitize(segment)
|
|
sanitizedSegments = append(sanitizedSegments, sanitized)
|
|
|
|
if replacement != "" {
|
|
replacements[segment] = replacement
|
|
}
|
|
}
|
|
|
|
return sanitizedSegments, replacements
|
|
}
|
|
|
|
// safeJoin joins r.BaseDir with elem and refuses to construct paths that
|
|
// would escape BaseDir. Each element must satisfy filepath.IsLocal — i.e.
|
|
// it must not be absolute, must not contain ".." segments, and (on Windows)
|
|
// must not name a reserved device. CodeQL recognises filepath.IsLocal as
|
|
// a path-traversal sanitiser, so taint analysis at call sites that hand the
|
|
// result to os.* terminates here.
|
|
func (r *Recorder) safeJoin(elem ...string) (string, error) {
|
|
if r.BaseDir == "" {
|
|
return "", fmt.Errorf("recorder: BaseDir not configured")
|
|
}
|
|
|
|
for _, e := range elem {
|
|
if e == "" {
|
|
// filepath.Join silently skips empty components, but
|
|
// filepath.IsLocal returns false for "" — treat empties as
|
|
// no-ops to preserve the existing call shapes.
|
|
continue
|
|
}
|
|
|
|
if !filepath.IsLocal(e) {
|
|
return "", fmt.Errorf("recorder: path component %q escapes BaseDir", e)
|
|
}
|
|
}
|
|
|
|
return filepath.Join(append([]string{r.BaseDir}, elem...)...), nil
|
|
}
|
|
|
|
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) (string, error) {
|
|
subDir := "root"
|
|
if len(sanitizedSegments) > 0 {
|
|
subDir = filepath.Join(sanitizedSegments...)
|
|
}
|
|
|
|
return r.safeJoin("interactions", r.SessionID, category, subDir)
|
|
}
|
|
|
|
func (r *Recorder) getRecordingPath(dir, method string) string {
|
|
timestamp := time.Now().Format("20060102-150405.000")
|
|
count := atomic.AddUint64(&r.counter, 1)
|
|
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
|
|
|
|
return filepath.Join(dir, filename)
|
|
}
|
|
|
|
func (r *Recorder) writeRequestWithEnrichment(buf *bytes.Buffer, req *http.Request, replacements map[string]string, enriched *EnrichedSCMUDCEvent) {
|
|
displayURL := req.URL.String()
|
|
for orig, repl := range replacements {
|
|
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
|
|
}
|
|
|
|
fmt.Fprintf(buf, "### %s %s\n", req.Method, displayURL)
|
|
|
|
for orig, repl := range replacements {
|
|
key := strings.Trim(repl, "{}")
|
|
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
|
|
}
|
|
|
|
// Add SCMUDC enriched comments
|
|
if enriched != nil {
|
|
enrichedComments := generateSCMUDCComments(enriched)
|
|
for _, comment := range enrichedComments {
|
|
fmt.Fprintf(buf, "%s\n", comment)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
|
|
fmt.Fprintf(buf, "Host: %s\n", req.Host)
|
|
|
|
for k, vv := range req.Header {
|
|
if r.Redact && isSensitive(k) {
|
|
fmt.Fprintf(buf, "%s: [REDACTED]\n", k)
|
|
continue
|
|
}
|
|
|
|
for _, v := range vv {
|
|
val := v
|
|
for orig, repl := range replacements {
|
|
val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}")
|
|
}
|
|
|
|
fmt.Fprintf(buf, "%s: %s\n", k, val)
|
|
}
|
|
}
|
|
|
|
buf.WriteString("\n")
|
|
|
|
if req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(req.Body)
|
|
if err == nil {
|
|
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
buf.Write(bodyBytes)
|
|
buf.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) writeResponseWithEnrichment(buf *bytes.Buffer, res *http.Response, enriched *EnrichedSCMUDCEvent) {
|
|
buf.WriteString("\n")
|
|
buf.WriteString("> {% \n")
|
|
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
|
|
|
|
// Add SCMUDC enrichment summary in response
|
|
if enriched != nil {
|
|
buf.WriteString(" //\n")
|
|
buf.WriteString(" // SCMUDC Event Analysis:\n")
|
|
fmt.Fprintf(buf, " // - Origin: %s (%s)\n", getOriginDescription(enriched.Origin), enriched.Origin)
|
|
fmt.Fprintf(buf, " // - Action: %s\n", enriched.Action)
|
|
fmt.Fprintf(buf, " // - Summary: %s\n", enriched.Summary)
|
|
|
|
if enriched.DecodedData != nil {
|
|
fmt.Fprintf(buf, " // - Content: %s\n", enriched.DecodedData.ItemName)
|
|
|
|
if enriched.DecodedData.SourceAccount != "" {
|
|
fmt.Fprintf(buf, " // - Account: %s\n", enriched.DecodedData.SourceAccount)
|
|
}
|
|
}
|
|
}
|
|
|
|
buf.WriteString(" //\n")
|
|
buf.WriteString(" // Headers:\n")
|
|
|
|
for k, vv := range res.Header {
|
|
if r.Redact && isSensitive(k) {
|
|
fmt.Fprintf(buf, " // %s: [REDACTED]\n", k)
|
|
continue
|
|
}
|
|
|
|
for _, v := range vv {
|
|
fmt.Fprintf(buf, " // %s: %s\n", k, v)
|
|
}
|
|
}
|
|
|
|
buf.WriteString("%}\n")
|
|
|
|
if res.Body != nil {
|
|
bodyBytes, err := io.ReadAll(res.Body)
|
|
if err == nil {
|
|
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
|
|
contentType := res.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") {
|
|
buf.WriteString("\n/*\n")
|
|
buf.Write(bodyBytes)
|
|
buf.WriteString("\n*/\n")
|
|
} else if len(bodyBytes) > 0 {
|
|
fmt.Fprintf(buf, "\n[Binary response body: %d bytes]\n", len(bodyBytes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) updateEnvFile(newVars map[string]string) error {
|
|
if len(newVars) == 0 {
|
|
return nil
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
changed := false
|
|
|
|
for orig, repl := range newVars {
|
|
key := strings.Trim(repl, "{}")
|
|
if r.variables[key] != orig {
|
|
r.variables[key] = orig
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if !changed {
|
|
return nil
|
|
}
|
|
|
|
envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json")
|
|
|
|
// Create the structure: {"session": {"key": "val"}}
|
|
content := map[string]map[string]string{
|
|
"session": r.variables,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(content, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.rootWriteFile(envFile, data, 0644)
|
|
}
|
|
|
|
// GetInteractionStats returns statistics about recorded interactions.
|
|
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
|
|
stats := &InteractionStats{
|
|
ByService: make(map[string]int),
|
|
BySession: make(map[string]int),
|
|
}
|
|
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
|
|
return stats, nil
|
|
}
|
|
|
|
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
|
|
stats.TotalRequests++
|
|
|
|
// Extract category (self/upstream) and session from path
|
|
// Path is like: .../interactions/<session>/<category>/...
|
|
rel, err := filepath.Rel(interactionsDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) >= 2 {
|
|
sessionID := parts[0]
|
|
category := parts[1]
|
|
stats.BySession[sessionID]++
|
|
stats.ByService[category]++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return stats, err
|
|
}
|
|
|
|
// ListInteractions returns a list of recorded interactions.
|
|
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
|
|
interactions := make([]Interaction, 0)
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
|
|
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
|
|
return interactions, nil
|
|
}
|
|
|
|
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(interactionsDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) < 3 {
|
|
return nil
|
|
}
|
|
|
|
sessionID, category := parts[0], parts[1]
|
|
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
|
|
return nil
|
|
}
|
|
|
|
interaction, ok := r.parseInteractionFile(rel, path, parts)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
if sinceFilter != "" && interaction.Timestamp != "" {
|
|
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
|
|
|
|
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
|
|
if fullTS != "" && fullTS < normalizedSince {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
interactions = append(interactions, interaction)
|
|
|
|
return nil
|
|
})
|
|
|
|
return interactions, err
|
|
}
|
|
|
|
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
|
|
sessionID, category := parts[0], parts[1]
|
|
filename := parts[len(parts)-1]
|
|
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
|
|
|
timestamp := ""
|
|
method, counter := "UNKNOWN", 0
|
|
|
|
if len(fnParts) >= 1 {
|
|
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
|
|
}
|
|
|
|
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
|
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
|
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
|
dateStr := fnParts[1] // yyyyMMdd
|
|
timeStr := fnParts[2] // HHMMSS.sss
|
|
method = fnParts[3]
|
|
|
|
// Format date: yyyyMMdd -> yyyy-MM-dd
|
|
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
|
|
|
// Format time: HHMMSS.sss -> HH:MM:SS.sss
|
|
if len(timeStr) >= 6 {
|
|
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
|
|
timestamp = date + " " + time
|
|
}
|
|
} else if len(fnParts) >= 5 {
|
|
// Legacy format: count-HH-MM-SS.sss-method.http
|
|
// Extract date from sessionID for backward compatibility
|
|
date := ""
|
|
if len(sessionID) >= 8 {
|
|
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
|
}
|
|
|
|
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
|
|
timestamp = timeStr
|
|
|
|
if date != "" {
|
|
timestamp = date + " " + timeStr
|
|
}
|
|
|
|
method = fnParts[4]
|
|
}
|
|
|
|
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
|
|
if requestPath == "/root" {
|
|
requestPath = "/"
|
|
}
|
|
|
|
interaction := Interaction{
|
|
ID: filename,
|
|
Session: sessionID,
|
|
Category: category,
|
|
Method: method,
|
|
Path: requestPath,
|
|
File: rel,
|
|
Counter: counter,
|
|
Status: r.peekStatus(path),
|
|
Timestamp: timestamp,
|
|
}
|
|
|
|
// Extract SCMUDC enrichment data if this is a SCMUDC request
|
|
if strings.Contains(requestPath, "/v1/scmudc/") {
|
|
interaction.SCMUDCData = r.extractSCMUDCFromFile(path)
|
|
}
|
|
|
|
return interaction, true
|
|
}
|
|
|
|
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
|
|
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
|
|
content, err := r.rootReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
var (
|
|
enriched EnrichedSCMUDCEvent
|
|
foundSCMUDC bool
|
|
bodyStart int
|
|
)
|
|
|
|
// Look for SCMUDC enrichment comments
|
|
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, "// Origin: "):
|
|
parts := strings.Split(line, " (")
|
|
if len(parts) >= 2 {
|
|
enriched.Origin = strings.TrimSuffix(parts[1], ")")
|
|
foundSCMUDC = true
|
|
}
|
|
case strings.HasPrefix(line, "// Action: "):
|
|
enriched.Action = strings.TrimPrefix(line, "// Action: ")
|
|
case strings.HasPrefix(line, "// Command: "):
|
|
enriched.Command = strings.TrimPrefix(line, "// Command: ")
|
|
case strings.HasPrefix(line, "// Summary: "):
|
|
enriched.Summary = strings.TrimPrefix(line, "// Summary: ")
|
|
case strings.HasPrefix(line, "// - Source: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ContentType = strings.TrimPrefix(line, "// - Source: ")
|
|
case strings.HasPrefix(line, "// - Item: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ItemName = strings.TrimPrefix(line, "// - Item: ")
|
|
case strings.HasPrefix(line, "// - Account: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.SourceAccount = strings.TrimPrefix(line, "// - Account: ")
|
|
case strings.HasPrefix(line, "// - Artwork: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ArtworkURL = strings.TrimPrefix(line, "// - Artwork: ")
|
|
case line == "// - Presetable: Yes":
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.IsPresetable = true
|
|
case line == "{" && i > 0:
|
|
// Found start of JSON body
|
|
bodyStart = i
|
|
goto endLoop
|
|
}
|
|
}
|
|
|
|
endLoop:
|
|
// If we didn't find enrichment comments but this is a SCMUDC request,
|
|
// try to parse the JSON body directly
|
|
if !foundSCMUDC && bodyStart > 0 {
|
|
if parsed := r.parseSCMUDBody(lines, bodyStart); parsed != nil {
|
|
return parsed
|
|
}
|
|
}
|
|
|
|
if !foundSCMUDC {
|
|
return nil
|
|
}
|
|
|
|
return &enriched
|
|
}
|
|
|
|
func (r *Recorder) ensureDecodedData(enriched *EnrichedSCMUDCEvent) {
|
|
if enriched.DecodedData == nil {
|
|
enriched.DecodedData = &DecodedContent{}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) parseSCMUDBody(lines []string, bodyStart int) *EnrichedSCMUDCEvent {
|
|
var bodyLines []string
|
|
|
|
inBody := false
|
|
braceCount := 0
|
|
|
|
for i := bodyStart; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if strings.TrimSpace(line) == "{" && !inBody {
|
|
inBody = true
|
|
|
|
bodyLines = append(bodyLines, line)
|
|
braceCount = 1
|
|
} else if inBody {
|
|
bodyLines = append(bodyLines, line)
|
|
|
|
braceCount += strings.Count(line, "{") - strings.Count(line, "}")
|
|
if braceCount == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(bodyLines) > 0 {
|
|
bodyJSON := strings.Join(bodyLines, "\n")
|
|
return enrichSCMUDCRequest([]byte(bodyJSON))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
|
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
|
|
|
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
|
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
|
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
|
dateStr := fnParts[1] // yyyyMMdd
|
|
timeStr := fnParts[2] // HHMMSS.sss
|
|
|
|
if len(timeStr) >= 6 {
|
|
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
|
time := timeStr[0:2] + "-" + timeStr[2:4] + "-" + timeStr[4:]
|
|
|
|
return date + "-" + time
|
|
}
|
|
} else if len(fnParts) >= 5 {
|
|
// Legacy format: count-HH-MM-SS.sss-method.http
|
|
if len(sessionID) < 8 {
|
|
return ""
|
|
}
|
|
|
|
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
|
|
|
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (r *Recorder) peekStatus(path string) int {
|
|
content, err := r.rootReadFile(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
for _, line := range lines {
|
|
if !strings.Contains(line, "// Response:") {
|
|
continue
|
|
}
|
|
|
|
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
|
|
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
|
|
trimmedLine = strings.TrimSpace(trimmedLine)
|
|
|
|
status := 0
|
|
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
|
|
|
|
return status
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// DeleteSession deletes a specific recording session.
|
|
func (r *Recorder) DeleteSession(sessionID string) error {
|
|
if sessionID == "" {
|
|
return fmt.Errorf("session ID is required")
|
|
}
|
|
|
|
sessionDir, err := r.safeJoin("interactions", sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.rootRemoveAll(sessionDir)
|
|
}
|
|
|
|
// CleanupSessions deletes all but the most recent keepCount sessions.
|
|
func (r *Recorder) CleanupSessions(keepCount int) error {
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
|
|
entries, err := r.rootReadDir(interactionsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
var sessions []os.DirEntry
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
sessions = append(sessions, entry)
|
|
}
|
|
}
|
|
|
|
if len(sessions) <= keepCount {
|
|
return nil
|
|
}
|
|
|
|
// Sort sessions by name (timestamp) descending to keep the newest ones
|
|
// Session ID format: 20260102-150405-PID
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].Name() > sessions[j].Name()
|
|
})
|
|
|
|
for i := keepCount; i < len(sessions); i++ {
|
|
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
|
|
if err := r.rootRemoveAll(sessionDir); err != nil {
|
|
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetInteractionContent returns the raw content of a recorded interaction.
|
|
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
|
|
fullPath, err := r.safeJoin("interactions", relPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return r.rootReadFile(fullPath)
|
|
}
|
|
|
|
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
|
|
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
|
|
sessionDir, err := r.safeJoin("interactions", sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, statErr := r.rootStat(sessionDir)
|
|
if statErr != nil {
|
|
return statErr
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", sessionID)
|
|
}
|
|
|
|
gw := gzip.NewWriter(w)
|
|
|
|
defer func() {
|
|
if closeErr := gw.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
tw := tar.NewWriter(gw)
|
|
|
|
defer func() {
|
|
if closeErr := tw.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
return filepath.Walk(sessionDir, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
|
|
header, hErr := tar.FileInfoHeader(info, info.Name())
|
|
if hErr != nil {
|
|
return hErr
|
|
}
|
|
|
|
rel, rErr := filepath.Rel(sessionDir, path)
|
|
if rErr != nil {
|
|
return rErr
|
|
}
|
|
|
|
header.Name = rel
|
|
|
|
if whErr := tw.WriteHeader(header); whErr != nil {
|
|
return whErr
|
|
}
|
|
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
f, oErr := r.rootOpen(path)
|
|
if oErr != nil {
|
|
return oErr
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
_, cErr := io.Copy(tw, f)
|
|
|
|
return cErr
|
|
})
|
|
}
|