From 394717fee9a1f3d1546cf0fc67d39833ba980e66 Mon Sep 17 00:00:00 2001 From: butschster Date: Sat, 4 Apr 2026 11:35:02 +0400 Subject: [PATCH 1/2] feat: add HTTP forward proxy module with MITM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new TCP module that acts as an HTTP forward proxy, capturing full request/response pairs and storing them as http-dump events. Supports HTTPS via MITM with in-memory CA certificates. Clients only need InsecureSkipVerify — no CA install required. --- cmd/buggregator/main.go | 6 + docker-compose.yaml | 1 + internal/app/config.go | 8 +- modules/proxy/certgen.go | 97 +++++++++++++++ modules/proxy/module.go | 45 +++++++ modules/proxy/payload.go | 93 +++++++++++++++ modules/proxy/server.go | 246 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 modules/proxy/certgen.go create mode 100644 modules/proxy/module.go create mode 100644 modules/proxy/payload.go create mode 100644 modules/proxy/server.go diff --git a/cmd/buggregator/main.go b/cmd/buggregator/main.go index 1964e7b..6f9c03f 100644 --- a/cmd/buggregator/main.go +++ b/cmd/buggregator/main.go @@ -19,6 +19,7 @@ import ( "github.com/buggregator/go-buggregator/modules/ray" "github.com/buggregator/go-buggregator/modules/sentry" "github.com/buggregator/go-buggregator/modules/sms" + "github.com/buggregator/go-buggregator/modules/proxy" smtpmod "github.com/buggregator/go-buggregator/modules/smtp" "github.com/buggregator/go-buggregator/modules/vardumper" "github.com/buggregator/go-buggregator/modules/webhooks" @@ -75,6 +76,7 @@ func main() { monologMod := monolog.New(cfg.MonologAddr) smtpMod := smtpmod.New(cfg.SMTPAddr, attachments, db) vardumperMod := vardumper.New(cfg.VarDumperAddr) + proxyMod := proxy.New(cfg.ProxyAddr) // Start VarDumper PHP parser (only if enabled). if enabled.IsEnabled("var-dump") { @@ -110,6 +112,9 @@ func main() { if enabled.IsEnabled("sms") { registry.Register(sms.New()) } + // HTTP proxy — stores events as http-dump with response data. + registry.Register(proxyMod) + // Register webhooks module if any webhooks are configured. if len(cfg.Webhooks) > 0 { whConfigs := make([]webhooks.WebhookConfig, len(cfg.Webhooks)) @@ -149,6 +154,7 @@ func main() { if enabled.IsEnabled("var-dump") { vardumperMod.SetEventService(eventService) } + proxyMod.SetEventService(eventService) application := app.New(cfg, db, registry, hub, store, attachments, collector) application.Run() diff --git a/docker-compose.yaml b/docker-compose.yaml index c67b908..1e1f8fd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,6 +54,7 @@ services: volumes: - ../examples/app:/app/app:ro - ../examples/routes:/app/routes:ro + - ../examples/resources:/app/resources:ro - ../examples/config/sentry.php:/app/config/sentry.php:ro networks: - buggregator diff --git a/internal/app/config.go b/internal/app/config.go index e349c57..b482656 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -43,6 +43,7 @@ type Config struct { SMTPAddr string `yaml:"-"` MonologAddr string `yaml:"-"` VarDumperAddr string `yaml:"-"` + ProxyAddr string `yaml:"-"` } // MCPConfig controls the MCP (Model Context Protocol) server. @@ -88,6 +89,7 @@ type TCPConfig struct { SMTP TCPServerConfig `yaml:"smtp"` Monolog TCPServerConfig `yaml:"monolog"` VarDumper TCPServerConfig `yaml:"var-dumper"` + Proxy TCPServerConfig `yaml:"proxy"` } type TCPServerConfig struct { @@ -178,6 +180,7 @@ func LoadConfig() Config { cfg.TCP.SMTP.Addr = coalesce(cfg.TCP.SMTP.Addr, os.Getenv("SMTP_ADDR"), fileCfg.TCP.SMTP.Addr, ":1025") cfg.TCP.Monolog.Addr = coalesce(cfg.TCP.Monolog.Addr, os.Getenv("MONOLOG_ADDR"), fileCfg.TCP.Monolog.Addr, ":9913") cfg.TCP.VarDumper.Addr = coalesce(cfg.TCP.VarDumper.Addr, os.Getenv("VAR_DUMPER_ADDR"), fileCfg.TCP.VarDumper.Addr, ":9912") + cfg.TCP.Proxy.Addr = coalesce(os.Getenv("PROXY_ADDR"), fileCfg.TCP.Proxy.Addr, ":8080") // Storage. cfg.Storage.Mode = coalesce(os.Getenv("STORAGE_MODE"), fileCfg.Storage.Mode, "memory") @@ -231,6 +234,7 @@ func LoadConfig() Config { cfg.SMTPAddr = cfg.TCP.SMTP.Addr cfg.MonologAddr = cfg.TCP.Monolog.Addr cfg.VarDumperAddr = cfg.TCP.VarDumper.Addr + cfg.ProxyAddr = cfg.TCP.Proxy.Addr return cfg } @@ -324,7 +328,7 @@ func coalesce(values ...string) string { } func (c Config) String() string { - return fmt.Sprintf("http=%s db=%s smtp=%s monolog=%s vardumper=%s modules=%v", + return fmt.Sprintf("http=%s db=%s smtp=%s monolog=%s vardumper=%s proxy=%s modules=%v", c.Server.Addr, c.Database.DSN, c.TCP.SMTP.Addr, c.TCP.Monolog.Addr, c.TCP.VarDumper.Addr, - c.Modules.EnabledTypes()) + c.TCP.Proxy.Addr, c.Modules.EnabledTypes()) } diff --git a/modules/proxy/certgen.go b/modules/proxy/certgen.go new file mode 100644 index 0000000..20fbec2 --- /dev/null +++ b/modules/proxy/certgen.go @@ -0,0 +1,97 @@ +package proxy + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "time" +) + +// generateCA creates a self-signed CA certificate in memory. +func generateCA() (*tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"Buggregator Proxy CA"}, + CommonName: "Buggregator Proxy CA", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return &tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + Leaf: cert, + }, nil +} + +// generateHostCert creates a TLS certificate for the given host, signed by the CA. +func generateHostCert(ca *tls.Certificate, host string) (*tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: host, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + } + + if ip := net.ParseIP(host); ip != nil { + template.IPAddresses = []net.IP{ip} + } else { + template.DNSNames = []string{host} + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Leaf, &key.PublicKey, ca.PrivateKey) + if err != nil { + return nil, err + } + + return &tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }, nil +} diff --git a/modules/proxy/module.go b/modules/proxy/module.go new file mode 100644 index 0000000..6b41911 --- /dev/null +++ b/modules/proxy/module.go @@ -0,0 +1,45 @@ +package proxy + +import ( + "context" + + "github.com/buggregator/go-buggregator/internal/event" + "github.com/buggregator/go-buggregator/internal/module" + "github.com/buggregator/go-buggregator/internal/server/tcp" +) + +// Module implements an HTTP forward proxy that captures request/response pairs +// and stores them as "http-dump" events with an additional response and proxy flag. +type Module struct { + module.BaseModule + addr string + eventService EventStorer +} + +type EventStorer interface { + HandleIncoming(ctx context.Context, inc *event.Incoming) error +} + +func New(addr string) *Module { + return &Module{addr: addr} +} + +func (m *Module) SetEventService(es EventStorer) { + m.eventService = es +} + +func (m *Module) Name() string { return "HTTP Proxy" } +func (m *Module) Type() string { return "http-dump" } + +func (m *Module) TCPServers() []tcp.ServerConfig { + if m.eventService == nil { + return nil + } + return []tcp.ServerConfig{ + { + Name: "http-proxy", + Address: m.addr, + Starter: newProxyServer(m.addr, m.eventService), + }, + } +} diff --git a/modules/proxy/payload.go b/modules/proxy/payload.go new file mode 100644 index 0000000..512d9ad --- /dev/null +++ b/modules/proxy/payload.go @@ -0,0 +1,93 @@ +package proxy + +import ( + "encoding/json" + "time" + + "github.com/buggregator/go-buggregator/internal/event" +) + +const maxBodySize = 512 * 1024 // 512KB + +// buildHTTPDumpEvent creates an http-dump event from a proxied request/response pair. +// The payload matches the http-dump format with additional "response" and "proxy" fields. +func buildHTTPDumpEvent(req *capturedRequest, resp *capturedResponse, durationMs float64, proxyErr string) *event.Incoming { + headers := make(map[string][]string) + for k, v := range req.Headers { + headers[k] = v + } + + query := make(map[string]any) + for k, v := range req.Query { + if len(v) == 1 { + query[k] = v[0] + } else { + query[k] = v + } + } + + payload := map[string]any{ + "received_at": time.Now().Format("2006-01-02 15:04:05"), + "host": req.Host, + "proxy": true, + "duration_ms": durationMs, + "request": map[string]any{ + "method": req.Method, + "uri": req.URI, + "headers": headers, + "body": truncateBody(req.Body), + "query": query, + "post": map[string]any{}, + "cookies": map[string]string{}, + "files": []any{}, + }, + } + + if proxyErr != "" { + payload["error"] = proxyErr + } + + if resp != nil { + respHeaders := make(map[string][]string) + for k, v := range resp.Headers { + respHeaders[k] = v + } + payload["response"] = map[string]any{ + "status_code": resp.StatusCode, + "headers": respHeaders, + "body": truncateBody(resp.Body), + } + } + + b, _ := json.Marshal(payload) + + return &event.Incoming{ + UUID: event.GenerateUUID(), + Type: "http-dump", + Payload: json.RawMessage(b), + } +} + +type capturedRequest struct { + Method string + URI string + Host string + Scheme string + Headers map[string][]string + Query map[string][]string + Body []byte +} + +type capturedResponse struct { + StatusCode int + Headers map[string][]string + Body []byte +} + +// truncateBody caps body at maxBodySize and marks it as truncated. +func truncateBody(body []byte) string { + if len(body) <= maxBodySize { + return string(body) + } + return string(body[:maxBodySize]) + "\n[truncated]" +} diff --git a/modules/proxy/server.go b/modules/proxy/server.go new file mode 100644 index 0000000..a24b1db --- /dev/null +++ b/modules/proxy/server.go @@ -0,0 +1,246 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// proxyServer implements tcp.Starter. +type proxyServer struct { + addr string + eventService EventStorer + ca *tls.Certificate + certCache sync.Map // host -> *tls.Certificate + listener net.Listener +} + +func newProxyServer(addr string, es EventStorer) *proxyServer { + return &proxyServer{addr: addr, eventService: es} +} + +func (s *proxyServer) Start(ctx context.Context) error { + ca, err := generateCA() + if err != nil { + return fmt.Errorf("proxy: generate CA: %w", err) + } + s.ca = ca + + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("proxy: listen %s: %w", s.addr, err) + } + s.listener = ln + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + slog.Error("proxy: accept error", "err", err) + continue + } + } + go s.handleConn(ctx, conn) + } + }() + + go func() { + <-ctx.Done() + ln.Close() + }() + + return nil +} + +func (s *proxyServer) Stop() error { + if s.listener != nil { + return s.listener.Close() + } + return nil +} + +func (s *proxyServer) handleConn(ctx context.Context, conn net.Conn) { + defer conn.Close() + + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return + } + + if req.Method == http.MethodConnect { + s.handleConnect(ctx, conn, req) + } else { + s.handleHTTP(ctx, conn, req) + } +} + +// handleConnect handles HTTPS CONNECT tunneling with MITM. +func (s *proxyServer) handleConnect(ctx context.Context, conn net.Conn, connectReq *http.Request) { + host := connectReq.Host + if !strings.Contains(host, ":") { + host += ":443" + } + hostname, _, _ := net.SplitHostPort(host) + + // Respond 200 to establish the tunnel. + conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + + // Get or create a TLS certificate for this host. + cert, err := s.certForHost(hostname) + if err != nil { + slog.Error("proxy: cert generation failed", "host", hostname, "err", err) + return + } + + // TLS handshake with the client. + tlsConn := tls.Server(conn, &tls.Config{ + Certificates: []tls.Certificate{*cert}, + }) + if err := tlsConn.Handshake(); err != nil { + slog.Debug("proxy: client TLS handshake failed", "host", hostname, "err", err) + return + } + defer tlsConn.Close() + + clientReader := bufio.NewReader(tlsConn) + + // Handle multiple requests over the same connection (HTTP/1.1 keep-alive). + for { + req, err := http.ReadRequest(clientReader) + if err != nil { + return + } + + req.URL.Scheme = "https" + req.URL.Host = host + req.RequestURI = "" + + s.proxyAndRecord(ctx, tlsConn, req, "https") + } +} + +// handleHTTP handles plain HTTP proxy requests. +func (s *proxyServer) handleHTTP(ctx context.Context, conn net.Conn, req *http.Request) { + req.RequestURI = "" + if req.URL.Scheme == "" { + req.URL.Scheme = "http" + } + s.proxyAndRecord(ctx, conn, req, "http") +} + +// proxyAndRecord forwards the request, records the exchange, and writes the response back. +func (s *proxyServer) proxyAndRecord(ctx context.Context, w io.Writer, req *http.Request, scheme string) { + start := time.Now() + + // Capture request body. + var reqBody []byte + if req.Body != nil { + reqBody, _ = io.ReadAll(io.LimitReader(req.Body, maxBodySize+1)) + req.Body = io.NopCloser(strings.NewReader(string(reqBody))) + } + + // Remove hop-by-hop headers. + removeHopHeaders(req.Header) + + captured := &capturedRequest{ + Method: req.Method, + URI: req.URL.Path, + Host: req.URL.Host, + Scheme: scheme, + Headers: req.Header, + Query: req.URL.Query(), + Body: reqBody, + } + + // Forward the request to the real server. + transport := &http.Transport{ + TLSClientConfig: &tls.Config{}, + } + resp, err := transport.RoundTrip(req) + durationMs := float64(time.Since(start).Milliseconds()) + + if err != nil { + // Send 502 back to the client. + fmt.Fprintf(w, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + s.storeEvent(ctx, captured, nil, durationMs, err.Error()) + return + } + defer resp.Body.Close() + + // Capture response body. + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1)) + + capturedResp := &capturedResponse{ + StatusCode: resp.StatusCode, + Headers: resp.Header, + Body: respBody, + } + + // Write the response back to the client. + writeResponse(w, resp, respBody) + + s.storeEvent(ctx, captured, capturedResp, durationMs, "") +} + +func writeResponse(w io.Writer, resp *http.Response, body []byte) { + fmt.Fprintf(w, "HTTP/%d.%d %s\r\n", resp.ProtoMajor, resp.ProtoMinor, resp.Status) + resp.Header.Del("Transfer-Encoding") + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) + resp.Header.Write(w) + fmt.Fprintf(w, "\r\n") + w.Write(body) +} + +func (s *proxyServer) storeEvent(ctx context.Context, req *capturedRequest, resp *capturedResponse, durationMs float64, proxyErr string) { + inc := buildHTTPDumpEvent(req, resp, durationMs, proxyErr) + if err := s.eventService.HandleIncoming(ctx, inc); err != nil { + slog.Error("proxy: failed to store event", "err", err) + } +} + +func (s *proxyServer) certForHost(host string) (*tls.Certificate, error) { + if cached, ok := s.certCache.Load(host); ok { + return cached.(*tls.Certificate), nil + } + cert, err := generateHostCert(s.ca, host) + if err != nil { + return nil, err + } + s.certCache.Store(host, cert) + return cert, nil +} + +var hopHeaders = []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", + "Proxy-Authorization", "Te", "Trailer", + "Transfer-Encoding", "Upgrade", +} + +func removeHopHeaders(h http.Header) { + for _, hdr := range hopHeaders { + h.Del(hdr) + } +} + +// parseRequestURI extracts the path from an absolute proxy URI. +func parseRequestURI(rawURI string) string { + u, err := url.Parse(rawURI) + if err != nil { + return rawURI + } + return u.Path +} From cc2c91bfbc53e697df163f4ca2885eb8a898df46 Mon Sep 17 00:00:00 2001 From: butschster Date: Sat, 4 Apr 2026 12:46:08 +0400 Subject: [PATCH 2/2] chore: bump frontend version to 1.32.0 and update related references --- .github/workflows/release.yml | 2 +- .gitignore | 2 +- Dockerfile | 2 +- Makefile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2d5b0f..eb27307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ permissions: packages: write env: - FRONTEND_VERSION: "1.31.0" + FRONTEND_VERSION: "1.32.0" PHP_VERSION: "8.4.19" jobs: diff --git a/.gitignore b/.gitignore index d8e8e64..0ad2ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ php/vardumper/box.phar php/vardumper/micro-*.sfx php/vardumper/vardumper-parser.phar modules/vardumper/bin/vardumper-parser-* -internal/frontend/dist \ No newline at end of file +/internal/frontend/dist/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 602f9ec..d445aa0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN ARCH=$(case ${TARGETARCH} in amd64) echo "x86_64";; arm64) echo "aarch64";; # Stage 2: Download frontend FROM alpine:3.20 AS frontend -ARG FRONTEND_VERSION=1.31.0 +ARG FRONTEND_VERSION=1.32.0 RUN apk add --no-cache curl unzip \ && mkdir -p /frontend \ && curl -sL "https://github.com/buggregator/frontend/releases/download/${FRONTEND_VERSION}/frontend-${FRONTEND_VERSION}.zip" -o /tmp/fe.zip \ diff --git a/Makefile b/Makefile index b7bb382..21554e3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -FRONTEND_VERSION ?= 1.31.0 +FRONTEND_VERSION ?= 1.32.0 FRONTEND_URL = https://github.com/buggregator/frontend/releases/download/$(FRONTEND_VERSION)/frontend-$(FRONTEND_VERSION).zip FRONTEND_DIR = internal/frontend/dist BINARY = buggregator