Add app analyzing/debugging docs and scripts (#174)

This commit is contained in:
Tobias Gesellchen
2026-04-19 22:27:54 +02:00
committed by GitHub
parent 88c83b6131
commit 747a9cec97
12 changed files with 1223 additions and 23 deletions
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
)
type Interaction struct {
Type string `json:"type"` // ToNative, FromNative, ToNetwork
Payload string `json:"payload"` // Raw string
Parsed interface{} `json:"parsed"` // JSON if possible
Timestamp string `json:"timestamp"` // If available
ID string `json:"id"` // Internal ID if available
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run extract-log-interactions.go <log_file>")
return
}
logFile := os.Args[1]
file, err := os.Open(logFile)
if err != nil {
fmt.Printf("Error opening file: %v\n", err)
return
}
defer file.Close()
// Regex patterns
toNativeRegex := regexp.MustCompile(`To Native : "(.*)"`)
fromNativeRegex := regexp.MustCompile(`From Native : "(.*)"`)
toNetworkRegex := regexp.MustCompile(`To Network "(.*)"`)
timestampRegex := regexp.MustCompile(`Js_Console_Msg: "(\d{2}:\d{2}:\d{2}\.\d{3})`)
scanner := bufio.NewScanner(file)
lastTimestamp := ""
fmt.Println("### Bose SoundTouch Internal Log Interactions")
fmt.Println("-------------------------------------------------")
for scanner.Scan() {
line := scanner.Text()
// Track timestamp from console msgs
if tsMatch := timestampRegex.FindStringSubmatch(line); len(tsMatch) > 1 {
lastTimestamp = tsMatch[1]
}
if match := toNetworkRegex.FindStringSubmatch(line); len(match) > 1 {
printAppInteraction("TO NETWORK", match[1], lastTimestamp, "")
} else if match := toNativeRegex.FindStringSubmatch(line); len(match) > 1 {
payload := cleanPayload(match[1])
id := extractID(payload)
printAppInteraction("TO NATIVE", payload, lastTimestamp, id)
} else if match := fromNativeRegex.FindStringSubmatch(line); len(match) > 1 {
payload := cleanPayload(match[1])
id := extractID(payload)
printAppInteraction("FROM NATIVE", payload, lastTimestamp, id)
}
}
if err := scanner.Err(); err != nil {
fmt.Printf("Error reading file: %v\n", err)
}
}
func cleanPayload(p string) string {
// Remove escaped quotes and leading/trailing quotes
p = strings.ReplaceAll(p, `\"`, `"`)
return p
}
func extractID(p string) string {
// Try to find "id":X
idRegex := regexp.MustCompile(`"id":\s*(\d+)`)
match := idRegex.FindStringSubmatch(p)
if len(match) > 1 {
return match[1]
}
return ""
}
func printAppInteraction(typ, payload, ts, id string) {
fmt.Printf("\n### %s", typ)
if ts != "" {
fmt.Printf(" [%s]", ts)
}
if id != "" {
fmt.Printf(" (ID: %s)", id)
}
fmt.Println()
// Try to prettify if it's JSON
var obj interface{}
if err := json.Unmarshal([]byte(payload), &obj); err == nil {
pretty, _ := json.MarshalIndent(obj, "", " ")
fmt.Printf("/*\n%s\n*/\n", string(pretty))
} else {
// Just print raw (might be XML or plain text)
fmt.Printf("/*\n%s\n*/\n", payload)
}
fmt.Println("-------------------------------------------------")
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
// This tool extracts WebSocket payloads and DNS queries from a .pcap file
// and prints them in a format compatible with soundtouch-service interactions.
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run scripts/extract-ws.go <pcap_file> [filter_ip]")
os.Exit(1)
}
pcapFile := os.Args[1]
filterIP := ""
if len(os.Args) > 2 {
filterIP = os.Args[2]
fmt.Printf("[DEBUG] Filtering WebSocket for IP: %s\n", filterIP)
}
handle, err := pcap.OpenOffline(pcapFile)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
fmt.Printf("[DEBUG] Reading file: %s\n", pcapFile)
// Prepare output files
baseName := strings.TrimSuffix(pcapFile, filepath.Ext(pcapFile))
wsFile, err := os.Create(baseName + ".ws.http")
if err != nil {
log.Fatal(err)
}
defer wsFile.Close()
dnsFile, err := os.Create(baseName + ".dns.txt")
if err != nil {
log.Fatal(err)
}
defer dnsFile.Close()
mdnsFile, err := os.Create(baseName + ".mdns.txt")
if err != nil {
log.Fatal(err)
}
defer mdnsFile.Close()
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Handle DNS
if dnsLayer := packet.Layer(layers.LayerTypeDNS); dnsLayer != nil {
dns, _ := dnsLayer.(*layers.DNS)
extractDNS(packet, dns, dnsFile, mdnsFile)
}
// Handle SSDP (UDP Port 1900)
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
udp, _ := udpLayer.(*layers.UDP)
if udp.DstPort == 1900 || udp.SrcPort == 1900 {
extractSSDP(packet, udp, baseName+".ssdp.txt")
}
}
// Handle WebSockets (TCP)
if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
tcp, _ := tcpLayer.(*layers.TCP)
extractWebSocket(packet, tcp, filterIP, wsFile)
}
}
fmt.Printf("[DEBUG] Extraction complete. Results written to:\n- %s\n- %s\n- %s\n- %s\n",
baseName+".ws.http", baseName+".dns.txt", baseName+".mdns.txt", baseName+".ssdp.txt")
}
func extractSSDP(packet gopacket.Packet, udp *layers.UDP, ssdpFilename string) {
payload := string(udp.Payload)
if !strings.Contains(payload, "HTTP/1.1") && !strings.Contains(payload, "NOTIFY") && !strings.Contains(payload, "M-SEARCH") {
return
}
f, err := os.OpenFile(ssdpFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
fmt.Fprintf(f, "[%s] %s:%d -> %s:%d\n", timestamp, srcIP, udp.SrcPort, dstIP, udp.DstPort)
fmt.Fprintf(f, "%s\n", strings.TrimSpace(payload))
fmt.Fprintln(f, "-------------------------------------------------")
}
func extractDNS(packet gopacket.Packet, dns *layers.DNS, dnsFile, mdnsFile *os.File) {
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
isMDNS := false
if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil {
udp, _ := udpLayer.(*layers.UDP)
if udp.DstPort == 5353 || udp.SrcPort == 5353 {
isMDNS = true
}
}
out := dnsFile
if isMDNS {
out = mdnsFile
}
timestamp := packet.Metadata().Timestamp.Format("2006-01-02 15:04:05.000")
prefix := fmt.Sprintf("[%s] %s -> %s", timestamp, srcIP, dstIP)
for _, q := range dns.Questions {
fmt.Fprintf(out, "%s | QUERY: %s (%s)\n", prefix, string(q.Name), q.Type)
}
for _, a := range dns.Answers {
val := ""
if a.IP != nil {
val = a.IP.String()
} else if len(a.CNAME) > 0 {
val = string(a.CNAME)
} else if len(a.PTR) > 0 {
val = string(a.PTR)
} else if len(a.TXTs) > 0 {
var txts []string
for _, t := range a.TXTs {
txts = append(txts, string(t))
}
val = strings.Join(txts, " ")
} else {
val = fmt.Sprintf("Type: %s", a.Type)
}
fmt.Fprintf(out, "%s | ANSWER: %s -> %s\n", prefix, string(a.Name), val)
}
}
func extractWebSocket(packet gopacket.Packet, tcp *layers.TCP, filterIP string, wsFile *os.File) {
srcIP := packet.NetworkLayer().NetworkFlow().Src().String()
dstIP := packet.NetworkLayer().NetworkFlow().Dst().String()
if filterIP != "" && srcIP != filterIP && dstIP != filterIP {
return
}
payload := tcp.Payload
if len(payload) == 0 {
return
}
// Check for WebSocket Frame (Sliding search)
for i := 0; i < len(payload)-2; i++ {
firstByte := payload[i]
// Opcode 1 (Text) or 2 (Binary).
if (firstByte&0xF0) == 0x80 && (firstByte&0x0F == 1 || firstByte&0x0F == 2) {
secondByte := payload[i+1]
mask := (secondByte & 0x80) != 0
length := int(secondByte & 0x7F)
offset := i + 2
if length == 126 {
if len(payload) < offset+2 {
continue
}
length = int(payload[offset])<<8 | int(payload[offset+1])
offset += 2
} else if length == 127 {
if len(payload) < offset+8 {
continue
}
length = int(payload[offset+4])<<24 | int(payload[offset+5])<<16 | int(payload[offset+6])<<8 | int(payload[offset+7])
offset += 8
}
if mask {
if len(payload) < offset+4+length {
continue
}
maskKey := payload[offset : offset+4]
offset += 4
data := make([]byte, length)
for j := 0; j < length; j++ {
data[j] = payload[offset+j] ^ maskKey[j%4]
}
printInteraction(packet, tcp, data, wsFile)
i = offset + length - 1
} else {
if len(payload) >= offset+length {
data := payload[offset : offset+length]
printInteraction(packet, tcp, data, wsFile)
i = offset + length - 1
}
}
}
}
}
func printInteraction(packet gopacket.Packet, tcp *layers.TCP, data []byte, out io.Writer) {
src := packet.NetworkLayer().NetworkFlow().Src().String()
dst := packet.NetworkLayer().NetworkFlow().Dst().String()
fmt.Fprintf(out, "### WebSocket Message: %s -> %s\n", src, dst)
fmt.Fprintf(out, "// Timestamp: %s\n", packet.Metadata().Timestamp)
fmt.Fprintf(out, "// Ports: %d -> %d\n", tcp.SrcPort, tcp.DstPort)
fmt.Fprintln(out)
// Try to detect if it's GZIP
content := ""
if len(data) > 2 && data[0] == 0x1f && data[1] == 0x8b {
fmt.Fprintln(out, "// [Detected GZIP compression]")
content = decompressGzip(data)
} else {
content = string(data)
}
fmt.Fprintln(out, "/*")
fmt.Fprintln(out, strings.TrimSpace(content))
fmt.Fprintln(out, "*/")
fmt.Fprintln(out, "")
fmt.Fprintln(out, "-------------------------------------------------")
fmt.Fprintln(out, "")
}
func decompressGzip(data []byte) string {
b := bytes.NewBuffer(data)
r, err := gzip.NewReader(b)
if err != nil {
return "[Error: Failed to create GZIP reader: " + err.Error() + "]"
}
defer r.Close()
res, err := io.ReadAll(r)
if err != nil {
return "[Error: Failed to decompress GZIP: " + err.Error() + "]"
}
return string(res)
}
+32
View File
@@ -0,0 +1,32 @@
# HTTP Body Diff Tool
This tool extracts and compares response bodies from two `.http` files.
It supports XML and JSON normalization (pretty-printing) and automatically masks common "noisy" fields like timestamps to make actual differences easier to spot.
## Usage
```bash
go run scripts/http-diff/main.go <path/to/file1.http> <path/to/file2.http>
```
To generate a side-by-side HTML report:
```bash
go run scripts/http-diff/main.go --html report.html <path/to/file1.http> <path/to/file2.http>
```
## Features
- **Body Extraction**: Automatically finds the response body within the `/* ... */` comment block at the end of the file.
- **Side-by-Side View**: Generates an HTML report with a clear side-by-side comparison.
- **Normalization**:
- Pretty-prints XML and JSON.
- Trims whitespace from XML character data.
- **Noise Reduction**:
- Automatically replaces ISO 8601 timestamps with `[TIMESTAMP]`.
- Masks specific XML tags: `<updatedOn>`, `<createdOn>`, `<lastModified>`, `<timestamp>`.
- Masks specific JSON keys: `timestamp`, `updatedOn`, `createdOn`, `expires_at`.
- **Diff Output**:
- Displays a line-by-line diff.
- Show context for unchanged parts (first and last two lines, with `...` in between).
- Uses `+` for additions and `-` for deletions.
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"flag"
"fmt"
"html"
"io"
"os"
"regexp"
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
)
func main() {
htmlOutput := flag.String("html", "", "Path to save HTML diff report")
flag.Parse()
args := flag.Args()
if len(args) < 2 {
fmt.Println("Usage: http-diff [options] <file1.http> <file2.http>")
fmt.Println("Options:")
flag.PrintDefaults()
os.Exit(1)
}
file1 := args[0]
file2 := args[1]
body1, err := extractBody(file1)
if err != nil {
fmt.Printf("Error extracting body from %s: %v\n", file1, err)
os.Exit(1)
}
body2, err := extractBody(file2)
if err != nil {
fmt.Printf("Error extracting body from %s: %v\n", file2, err)
os.Exit(1)
}
norm1 := normalize(body1)
norm2 := normalize(body2)
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(norm1, norm2, false)
lineDiffs := dmp.DiffCleanupSemantic(diffs)
if *htmlOutput != "" {
err := generateHTML(*htmlOutput, file1, file2, lineDiffs)
if err != nil {
fmt.Printf("Error generating HTML: %v\n", err)
os.Exit(1)
}
fmt.Printf("HTML report generated: %s\n", *htmlOutput)
return
}
// Custom line-by-line diff for better readability
for _, diff := range lineDiffs {
switch diff.Type {
case diffmatchpatch.DiffInsert:
lines := strings.Split(diff.Text, "\n")
for _, line := range lines {
if line != "" {
fmt.Printf("+ %s\n", line)
}
}
case diffmatchpatch.DiffDelete:
lines := strings.Split(diff.Text, "\n")
for _, line := range lines {
if line != "" {
fmt.Printf("- %s\n", line)
}
}
case diffmatchpatch.DiffEqual:
// Optionally skip unchanged lines or show context
lines := strings.Split(diff.Text, "\n")
// Filter out empty lines from splitting
var cleanLines []string
for _, l := range lines {
if strings.TrimSpace(l) != "" {
cleanLines = append(cleanLines, l)
}
}
if len(cleanLines) > 6 {
fmt.Printf(" %s\n", cleanLines[0])
fmt.Printf(" %s\n", cleanLines[1])
fmt.Printf(" ...\n")
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-2])
fmt.Printf(" %s\n", cleanLines[len(cleanLines)-1])
} else {
for _, line := range cleanLines {
fmt.Printf(" %s\n", line)
}
}
}
}
}
func extractBody(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
// Use a non-greedy regex to find the LAST /* ... */ block which typically contains the body
re := regexp.MustCompile(`(?s)/\*\s*(<\?xml.*?|\{.*?|\[.*?)\s*\*/`)
matches := re.FindAllStringSubmatch(string(content), -1)
if len(matches) > 0 {
// Return the last match as it's more likely to be the response body
lastMatch := matches[len(matches)-1]
return strings.TrimSpace(lastMatch[1]), nil
}
return "", fmt.Errorf("could not find body in /* ... */ block")
}
func normalize(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return ""
}
// Mask common timestamps and changing fields
timestampRegex := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})`)
body = timestampRegex.ReplaceAllString(body, "[TIMESTAMP]")
// Mask account IDs if they are variable, but usually they match in these files.
// Let's stick to timestamps for now.
// Try XML first
if strings.HasPrefix(body, "<?xml") || strings.Contains(body, "<") {
// For XML, let's also try to mask specific tags like <updatedOn> or <createdOn>
tagsToMask := []string{"updatedOn", "createdOn", "lastModified", "timestamp"}
for _, tag := range tagsToMask {
re := regexp.MustCompile(fmt.Sprintf(`<%s>.*?</%s>`, tag, tag))
body = re.ReplaceAllString(body, fmt.Sprintf("<%s>[MASKED]</%s>", tag, tag))
}
// Also mask empty attributes that might be noisy, like displayName=""
body = regexp.MustCompile(`\s+displayName=""`).ReplaceAllString(body, "")
var out bytes.Buffer
decoder := xml.NewDecoder(strings.NewReader(body))
encoder := xml.NewEncoder(&out)
encoder.Indent("", " ")
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
// If it's not valid XML, maybe it's just a fragment, continue or return body
break
}
// Trim whitespace from CharData to normalize
if cd, ok := token.(xml.CharData); ok {
token = xml.CharData(bytes.TrimSpace(cd))
}
err = encoder.EncodeToken(token)
if err != nil {
break
}
}
encoder.Flush()
if out.Len() > 0 {
return out.String()
}
}
// Try JSON
if strings.HasPrefix(body, "{") || strings.HasPrefix(body, "[") {
var obj interface{}
if err := json.Unmarshal([]byte(body), &obj); err == nil {
// Mask some JSON fields if they are common
maskJSON(obj)
pretty, _ := json.MarshalIndent(obj, "", " ")
return string(pretty)
}
}
return body
}
func maskJSON(data interface{}) {
switch v := data.(type) {
case map[string]interface{}:
for k, val := range v {
if strings.Contains(strings.ToLower(k), "timestamp") || k == "updatedOn" || k == "createdOn" || k == "expires_at" {
v[k] = "[MASKED]"
} else {
maskJSON(val)
}
}
case []interface{}:
for _, item := range v {
maskJSON(item)
}
}
}
func generateHTML(path, file1, file2 string, diffs []diffmatchpatch.Diff) error {
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>HTTP Diff Report</title>
<style>
body { font-family: monospace; line-height: 1.2; background: #f8f9fa; color: #212529; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 20px; border: 1px solid #dee2e6; border-radius: 4px; }
.header { margin-bottom: 20px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.diff-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.diff-table td { vertical-align: top; padding: 2px 4px; border: 1px solid #eee; overflow-wrap: break-word; }
.line-num { width: 40px; text-align: right; color: #999; background: #fdfdfd; user-select: none; }
.diff-equal { background: #fff; }
.diff-insert { background: #e6ffec; text-decoration: none; color: #1a7f37; }
.diff-delete { background: #ffebe9; text-decoration: none; color: #cf222e; }
.diff-change-marker { font-weight: bold; margin-right: 5px; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.files { font-size: 0.9rem; color: #666; }
pre { margin: 0; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>HTTP Response Body Diff</h1>
<div class="files">
Left: <strong>` + html.EscapeString(file1) + `</strong><br>
Right: <strong>` + html.EscapeString(file2) + `</strong>
</div>
</div>
<table class="diff-table">
`)
type sideBySideLine struct {
leftText string
rightText string
class string
}
var lines []sideBySideLine
for _, diff := range diffs {
text := html.EscapeString(diff.Text)
split := strings.Split(text, "\n")
// Remove trailing empty string from split if it exists
if len(split) > 0 && split[len(split)-1] == "" {
split = split[:len(split)-1]
}
switch diff.Type {
case diffmatchpatch.DiffEqual:
for _, line := range split {
lines = append(lines, sideBySideLine{leftText: line, rightText: line, class: "diff-equal"})
}
case diffmatchpatch.DiffInsert:
for _, line := range split {
lines = append(lines, sideBySideLine{leftText: "", rightText: line, class: "diff-insert"})
}
case diffmatchpatch.DiffDelete:
for _, line := range split {
lines = append(lines, sideBySideLine{leftText: line, rightText: "", class: "diff-delete"})
}
}
}
for i, line := range lines {
leftMarker := ""
rightMarker := ""
if line.class == "diff-insert" {
rightMarker = "+"
} else if line.class == "diff-delete" {
leftMarker = "-"
}
sb.WriteString(fmt.Sprintf(`
<tr class="%s">
<td class="line-num">%d</td>
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
<td class="line-num">%d</td>
<td><pre><span class="diff-change-marker">%s</span>%s</pre></td>
</tr>`, line.class, i+1, leftMarker, line.leftText, i+1, rightMarker, line.rightText))
}
sb.WriteString(`
</table>
</div>
</body>
</html>
`)
return os.WriteFile(path, []byte(sb.String()), 0644)
}