mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-09 23:59:45 +00:00
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
// Package httplog provides HTTP request/response logging for port forwards.
|
|
// It captures HTTP traffic passing through the forward proxy and stores
|
|
// entries for viewing in the UI.
|
|
//
|
|
// The logger supports:
|
|
// - Request and response capture with headers and bodies
|
|
// - Configurable body size limits to prevent memory issues
|
|
// - Callback-based notifications for real-time log viewing
|
|
// - Thread-safe operation for concurrent forwards
|
|
//
|
|
// Bodies are truncated if they exceed the configured maximum size
|
|
// (default: 1MB) and marked as truncated in the log entry.
|
|
package httplog
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// logBufferPool is used to reuse byte buffers for JSON encoding.
|
|
// This reduces allocations when serializing log entries.
|
|
var logBufferPool = sync.Pool{
|
|
New: func() interface{} {
|
|
return bytes.NewBuffer(make([]byte, 0, 4096))
|
|
},
|
|
}
|
|
|
|
// Entry represents a single HTTP log entry
|
|
type Entry struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
ForwardID string `json:"forward_id"`
|
|
RequestID string `json:"request_id"`
|
|
Direction string `json:"direction"`
|
|
Method string `json:"method,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Body string `json:"body,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
StatusCode int `json:"status_code,omitempty"`
|
|
BodySize int `json:"body_size"`
|
|
LatencyMs int64 `json:"latency_ms,omitempty"`
|
|
}
|
|
|
|
// LogCallback is a function that receives log entries
|
|
type LogCallback func(entry Entry)
|
|
|
|
// Logger writes HTTP log entries to an output stream
|
|
type Logger struct {
|
|
output io.Writer
|
|
file *os.File
|
|
forwardID string
|
|
callbacks []LogCallback
|
|
maxBodyLen int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewLogger creates a new HTTP logger
|
|
// If logFile is empty, logs only go to registered callbacks (no file output)
|
|
// This prevents stdout corruption when running in TUI mode
|
|
func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
|
|
l := &Logger{
|
|
forwardID: forwardID,
|
|
maxBodyLen: maxBodyLen,
|
|
}
|
|
|
|
if logFile == "" {
|
|
// Don't write to stdout - use io.Discard
|
|
// Log entries are delivered via callbacks to the UI
|
|
l.output = io.Discard
|
|
} else {
|
|
// #nosec G304 -- logFile is from config validation, not arbitrary user input
|
|
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.file = f
|
|
l.output = f
|
|
}
|
|
|
|
return l, nil
|
|
}
|
|
|
|
// AddCallback registers a callback to receive log entries
|
|
func (l *Logger) AddCallback(cb LogCallback) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.callbacks = append(l.callbacks, cb)
|
|
}
|
|
|
|
// ClearCallbacks removes all registered callbacks
|
|
func (l *Logger) ClearCallbacks() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.callbacks = nil
|
|
}
|
|
|
|
// stringBuilderPool provides reusable string builders for body truncation.
|
|
// This reduces allocations when building truncated body strings.
|
|
var stringBuilderPool = sync.Pool{
|
|
New: func() interface{} {
|
|
return &bytes.Buffer{}
|
|
},
|
|
}
|
|
|
|
// truncateBody truncates a body string to maxLen, adding a suffix if truncated.
|
|
// Uses a pooled buffer to avoid allocations during truncation.
|
|
func truncateBody(body string, maxLen int) string {
|
|
if len(body) <= maxLen {
|
|
return body
|
|
}
|
|
|
|
// Use pooled buffer for truncation
|
|
buf := stringBuilderPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
defer stringBuilderPool.Put(buf)
|
|
|
|
// Write truncated content
|
|
buf.WriteString(body[:maxLen])
|
|
buf.WriteString("...(truncated)")
|
|
return buf.String()
|
|
}
|
|
|
|
// Log writes a log entry as JSON using a pooled buffer to reduce allocations.
|
|
func (l *Logger) Log(entry Entry) error {
|
|
entry.ForwardID = l.forwardID
|
|
entry.Timestamp = time.Now()
|
|
|
|
// Truncate body if too large using pooled buffer
|
|
if len(entry.Body) > l.maxBodyLen {
|
|
entry.Body = truncateBody(entry.Body, l.maxBodyLen)
|
|
}
|
|
|
|
// Get a buffer from the pool
|
|
buf := logBufferPool.Get().(*bytes.Buffer)
|
|
buf.Reset() // Clear any previous content
|
|
defer logBufferPool.Put(buf)
|
|
|
|
// Encode JSON directly into the pooled buffer
|
|
encoder := json.NewEncoder(buf)
|
|
if err := encoder.Encode(entry); err != nil {
|
|
return err
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
// Notify callbacks
|
|
for _, cb := range l.callbacks {
|
|
cb(entry)
|
|
}
|
|
|
|
_, err := l.output.Write(buf.Bytes())
|
|
return err
|
|
}
|
|
|
|
// Close closes the logger
|
|
func (l *Logger) Close() error {
|
|
if l.file != nil {
|
|
return l.file.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetMaxBodyLen returns the maximum body length for logging
|
|
func (l *Logger) GetMaxBodyLen() int {
|
|
return l.maxBodyLen
|
|
}
|