Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:
packages: write

env:
FRONTEND_VERSION: "1.31.0"
FRONTEND_VERSION: "1.32.0"
PHP_VERSION: "8.4.19"

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
/internal/frontend/dist/*
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/buggregator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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())
}
97 changes: 97 additions & 0 deletions modules/proxy/certgen.go
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 45 additions & 0 deletions modules/proxy/module.go
Original file line number Diff line number Diff line change
@@ -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),
},
}
}
93 changes: 93 additions & 0 deletions modules/proxy/payload.go
Original file line number Diff line number Diff line change
@@ -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]"
}
Loading
Loading