|
| 1 | +// Package http1 provides HTTP/1.x protocol handling. |
| 2 | +// This file contains diagnostic tooling for debug builds. |
| 3 | +// Build with -tags=debug to include this functionality. |
| 4 | +// |
| 5 | +// DiagnosticWriter Usage: |
| 6 | +// |
| 7 | +// 1. Create a new writer with a target directory and unique suffix: |
| 8 | +// writer := NewDiagnosticWriter("/path/to/output", "conn123", logger) |
| 9 | +// |
| 10 | +// 2. Write raw HTTP traffic as it's captured: |
| 11 | +// writer.Write(rawData) |
| 12 | +// |
| 13 | +// 3. Close the writer when done (e.g., connection closes): |
| 14 | +// writer.Close() |
| 15 | +// |
| 16 | +// The writer generates two files per session: |
| 17 | +// - {timestamp}_{suffix}.log: Human-readable hex dumps with timestamps |
| 18 | +// - {timestamp}_{suffix}.json: Machine-parseable base64 payloads |
| 19 | +// |
| 20 | +// Example output location: |
| 21 | +// |
| 22 | +// /path/to/output/ |
| 23 | +// ├── http_dump_20240423_170800_conn123.log |
| 24 | +// └── http_dump_20240423_170800_conn123.json |
| 25 | +package http1 |
| 26 | + |
| 27 | +import ( |
| 28 | + "encoding/base64" |
| 29 | + "encoding/json" |
| 30 | + "fmt" |
| 31 | + "os" |
| 32 | + "path/filepath" |
| 33 | + "sync" |
| 34 | + "time" |
| 35 | + |
| 36 | + "go.uber.org/zap" |
| 37 | +) |
| 38 | + |
| 39 | +// PayloadEntry represents a captured HTTP payload with ordering metadata |
| 40 | +type PayloadEntry struct { |
| 41 | + Sequence int `json:"sequence"` |
| 42 | + Data string `json:"data"` // base64 encoded |
| 43 | +} |
| 44 | + |
| 45 | +// DiagnosticWriter captures raw HTTP traffic for debugging purposes. |
| 46 | +// It writes hex dumps to a .log file and base64-encoded payloads to a .json file. |
| 47 | +type DiagnosticWriter struct { |
| 48 | + enabled bool // whether writer is active |
| 49 | + directory string // output directory for diagnostic files |
| 50 | + baseFilename string // shared prefix for .log and .json files |
| 51 | + logFilePath string // path to detailed hex dump log |
| 52 | + jsonFilePath string // path to payload JSON array |
| 53 | + logger *zap.Logger |
| 54 | + payloads [][]byte // ordered list of captured payloads |
| 55 | + payloadsMutex sync.Mutex // guards payloads slice |
| 56 | + sequence int // monotonic counter for ordering |
| 57 | +} |
| 58 | + |
| 59 | +// NewDiagnosticWriter creates a writer that outputs to timestamp-based files in the given directory. |
| 60 | +// Returns a disabled writer if directory creation fails. |
| 61 | +func NewDiagnosticWriter(directory string, suffix string, logger *zap.Logger) *DiagnosticWriter { |
| 62 | + if logger == nil { |
| 63 | + logger = zap.NewNop() // Use a no-op logger if none provided |
| 64 | + } |
| 65 | + |
| 66 | + // Create timestamp-based base filename (e.g., http_dump_20250423_170800) |
| 67 | + timestamp := time.Now().Format("20060102_150405") |
| 68 | + baseFilename := fmt.Sprintf("http_dump_%s_%s", timestamp, suffix) |
| 69 | + |
| 70 | + // Ensure the diagnostics directory exists |
| 71 | + if err := os.MkdirAll(directory, 0755); err != nil { |
| 72 | + logger.Error("Failed to create diagnostics directory, disabling writer", |
| 73 | + zap.String("directory", directory), |
| 74 | + zap.Error(err)) |
| 75 | + // Return a disabled writer if directory creation fails |
| 76 | + return &DiagnosticWriter{enabled: false, logger: logger} |
| 77 | + } |
| 78 | + |
| 79 | + // Construct full paths for both log and JSON files |
| 80 | + logFilePath := filepath.Join(directory, baseFilename+".log") |
| 81 | + jsonFilePath := filepath.Join(directory, baseFilename+".json") |
| 82 | + |
| 83 | + logger.Info("Diagnostic writer initialized", |
| 84 | + zap.String("logFile", logFilePath), |
| 85 | + zap.String("jsonFile", jsonFilePath)) |
| 86 | + |
| 87 | + return &DiagnosticWriter{ |
| 88 | + enabled: true, |
| 89 | + directory: directory, |
| 90 | + baseFilename: baseFilename, |
| 91 | + logFilePath: logFilePath, |
| 92 | + jsonFilePath: jsonFilePath, |
| 93 | + logger: logger, |
| 94 | + payloads: make([][]byte, 0, 100), // Initialize slice with some capacity |
| 95 | + payloadsMutex: sync.Mutex{}, // Initialize the mutex |
| 96 | + sequence: 0, // Initialize sequence counter |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// Write captures and stores a copy of the raw data, writing hex dumps to the log file. |
| 101 | +func (d *DiagnosticWriter) Write(data []byte) { |
| 102 | + if !d.enabled { |
| 103 | + return // Do nothing if the writer is disabled |
| 104 | + } |
| 105 | + |
| 106 | + // --- Store payload for JSON --- |
| 107 | + // We must make a copy because the input slice 'data' might be reused |
| 108 | + // by the caller after this function returns. |
| 109 | + dataCopy := make([]byte, len(data)) |
| 110 | + copy(dataCopy, data) |
| 111 | + |
| 112 | + // Lock the mutex before accessing the shared payloads slice |
| 113 | + d.payloadsMutex.Lock() |
| 114 | + d.payloads = append(d.payloads, dataCopy) |
| 115 | + d.sequence++ |
| 116 | + currentSeq := d.sequence |
| 117 | + d.payloadsMutex.Unlock() // Unlock immediately after appending |
| 118 | + // ----------------------------- |
| 119 | + |
| 120 | + // --- Append to log file (existing functionality) --- |
| 121 | + // This writes the hex dump and raw string to the .log file for each chunk |
| 122 | + d.appendToLogFile(d.logFilePath, data, currentSeq) |
| 123 | + // ------------------------------------------------- |
| 124 | +} |
| 125 | + |
| 126 | +// Close writes accumulated payloads to the JSON file and disables the writer. |
| 127 | +func (d *DiagnosticWriter) Close() error { |
| 128 | + if !d.enabled { |
| 129 | + return nil // Nothing to do if already closed or disabled initially |
| 130 | + } |
| 131 | + |
| 132 | + d.logger.Info("Closing diagnostic writer", zap.Any("payloads", d.payloads)) |
| 133 | + |
| 134 | + // Lock the mutex to safely access and modify shared state (payloads, enabled) |
| 135 | + d.payloadsMutex.Lock() |
| 136 | + defer d.payloadsMutex.Unlock() // Ensure mutex is unlocked even if errors occur |
| 137 | + |
| 138 | + // Mark as disabled *now* to prevent any writes happening concurrently during the file write. |
| 139 | + d.enabled = false |
| 140 | + |
| 141 | + // Create ordered payload entries with sequence numbers |
| 142 | + entries := make([]PayloadEntry, len(d.payloads)) |
| 143 | + for i, p := range d.payloads { |
| 144 | + entries[i] = PayloadEntry{ |
| 145 | + Sequence: i + 1, // Use 1-based sequence numbers |
| 146 | + Data: base64.StdEncoding.EncodeToString(p), |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // Marshal the array of PayloadEntry structs into JSON format with indentation |
| 151 | + jsonData, err := json.MarshalIndent(entries, "", " ") |
| 152 | + if err != nil { |
| 153 | + d.logger.Error("Failed to marshal payloads to JSON", zap.Error(err)) |
| 154 | + // Clear slice to release memory even on error, then return |
| 155 | + d.payloads = nil |
| 156 | + return fmt.Errorf("failed to marshal JSON data: %w", err) |
| 157 | + } |
| 158 | + |
| 159 | + // Write the JSON data to the target file |
| 160 | + err = os.WriteFile(d.jsonFilePath, jsonData, 0644) |
| 161 | + if err != nil { |
| 162 | + d.logger.Error("Failed to write JSON payload file", zap.String("file", d.jsonFilePath), zap.Error(err)) |
| 163 | + // Clear slice and return error |
| 164 | + d.payloads = nil |
| 165 | + return fmt.Errorf("failed to write JSON file '%s': %w", d.jsonFilePath, err) |
| 166 | + } |
| 167 | + |
| 168 | + d.logger.Info("Successfully wrote JSON payload file", |
| 169 | + zap.String("file", d.jsonFilePath), |
| 170 | + zap.Int("payloadCount", len(entries))) |
| 171 | + |
| 172 | + // Clear the payloads slice to release the stored data from memory after successful write |
| 173 | + d.payloads = nil |
| 174 | + |
| 175 | + return nil |
| 176 | +} |
| 177 | + |
| 178 | +// appendToLogFile writes a timestamped hex dump and raw string representation of the data. |
| 179 | +func (d *DiagnosticWriter) appendToLogFile(filepath string, data []byte, sequence int) { |
| 180 | + // Open the log file in append mode, creating it if it doesn't exist. |
| 181 | + f, err := os.OpenFile(filepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) |
| 182 | + if err != nil { |
| 183 | + d.logger.Error("Failed to open diagnostic log file for appending", zap.String("file", filepath), zap.Error(err)) |
| 184 | + return // Cannot proceed if file can't be opened |
| 185 | + } |
| 186 | + defer f.Close() // Ensure file is closed when function exits |
| 187 | + |
| 188 | + // Write a separator block including a timestamp, sequence number, and chunk length |
| 189 | + timestamp := time.Now().Format("2006-01-02 15:04:05.000") |
| 190 | + separator := fmt.Sprintf("\n\n===== CHUNK #%d AT %s (LENGTH: %d) =====\n", sequence, timestamp, len(data)) |
| 191 | + if _, err := f.WriteString(separator); err != nil { |
| 192 | + d.logger.Warn("Failed to write separator to log file", zap.Error(err)) |
| 193 | + // Continue trying to write data even if separator fails |
| 194 | + } |
| 195 | + |
| 196 | + // Generate and write the hex dump of the data |
| 197 | + hexDump := formatHexDump(data) |
| 198 | + if _, err := f.WriteString(hexDump); err != nil { |
| 199 | + d.logger.Warn("Failed to write hex dump to log file", zap.Error(err)) |
| 200 | + // Continue trying to write raw string |
| 201 | + } |
| 202 | + |
| 203 | + // Add the raw string representation (be cautious with binary data) |
| 204 | + // This might produce unreadable output if data is not text. |
| 205 | + strDump := fmt.Sprintf("\n----- RAW STRING -----\n%s\n----- END RAW -----\n", string(data)) |
| 206 | + if _, err := f.WriteString(strDump); err != nil { |
| 207 | + d.logger.Warn("Failed to write raw string to log file", zap.Error(err)) |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +// formatHexDump creates a standard 16-byte-width hex dump with ASCII representation. |
| 212 | +func formatHexDump(data []byte) string { |
| 213 | + var result string |
| 214 | + const bytesPerRow = 16 // Standard width for hex dumps |
| 215 | + |
| 216 | + for i := 0; i < len(data); i += bytesPerRow { |
| 217 | + // Add the offset at the beginning of the line |
| 218 | + result += fmt.Sprintf("%08x ", i) |
| 219 | + |
| 220 | + // Get the slice for the current row (up to bytesPerRow) |
| 221 | + chunk := data[i:] |
| 222 | + if len(chunk) > bytesPerRow { |
| 223 | + chunk = chunk[:bytesPerRow] |
| 224 | + } |
| 225 | + |
| 226 | + // Add the hex byte representation |
| 227 | + for j := range bytesPerRow { |
| 228 | + if j < len(chunk) { |
| 229 | + result += fmt.Sprintf("%02x ", chunk[j]) // Print byte as 2-digit hex |
| 230 | + } else { |
| 231 | + result += " " // Pad with spaces if row is shorter |
| 232 | + } |
| 233 | + // Add an extra space halfway through the hex bytes for readability |
| 234 | + if j == bytesPerRow/2-1 { |
| 235 | + result += " " |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + // Add the ASCII representation part |
| 240 | + result += " |" // Separator |
| 241 | + for j := range chunk { |
| 242 | + b := chunk[j] |
| 243 | + // Use '.' for non-printable characters, otherwise print the character |
| 244 | + if b >= 32 && b <= 126 { |
| 245 | + result += string(b) |
| 246 | + } else { |
| 247 | + result += "." |
| 248 | + } |
| 249 | + } |
| 250 | + // Pad the ASCII part with spaces if the row is shorter than bytesPerRow |
| 251 | + for j := len(chunk); j < bytesPerRow; j++ { |
| 252 | + result += " " |
| 253 | + } |
| 254 | + result += "|\n" // End of ASCII part and newline |
| 255 | + } |
| 256 | + |
| 257 | + return result |
| 258 | +} |
0 commit comments