Skip to content

Commit 9c1fa03

Browse files
authored
Merge pull request #327 from buggregator/feat/http-proxy-module
feat: add HTTP forward proxy module with MITM support
2 parents 23149cc + cc2c91b commit 9c1fa03

11 files changed

Lines changed: 498 additions & 6 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ permissions:
1010
packages: write
1111

1212
env:
13-
FRONTEND_VERSION: "1.31.0"
13+
FRONTEND_VERSION: "1.32.0"
1414
PHP_VERSION: "8.4.19"
1515

1616
jobs:

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ php/vardumper/box.phar
66
php/vardumper/micro-*.sfx
77
php/vardumper/vardumper-parser.phar
88
modules/vardumper/bin/vardumper-parser-*
9-
internal/frontend/dist
9+
/internal/frontend/dist/*

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ RUN ARCH=$(case ${TARGETARCH} in amd64) echo "x86_64";; arm64) echo "aarch64";;
2525

2626
# Stage 2: Download frontend
2727
FROM alpine:3.20 AS frontend
28-
ARG FRONTEND_VERSION=1.31.0
28+
ARG FRONTEND_VERSION=1.32.0
2929
RUN apk add --no-cache curl unzip \
3030
&& mkdir -p /frontend \
3131
&& curl -sL "https://github.com/buggregator/frontend/releases/download/${FRONTEND_VERSION}/frontend-${FRONTEND_VERSION}.zip" -o /tmp/fe.zip \

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FRONTEND_VERSION ?= 1.31.0
1+
FRONTEND_VERSION ?= 1.32.0
22
FRONTEND_URL = https://github.com/buggregator/frontend/releases/download/$(FRONTEND_VERSION)/frontend-$(FRONTEND_VERSION).zip
33
FRONTEND_DIR = internal/frontend/dist
44
BINARY = buggregator

cmd/buggregator/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/buggregator/go-buggregator/modules/ray"
2020
"github.com/buggregator/go-buggregator/modules/sentry"
2121
"github.com/buggregator/go-buggregator/modules/sms"
22+
"github.com/buggregator/go-buggregator/modules/proxy"
2223
smtpmod "github.com/buggregator/go-buggregator/modules/smtp"
2324
"github.com/buggregator/go-buggregator/modules/vardumper"
2425
"github.com/buggregator/go-buggregator/modules/webhooks"
@@ -75,6 +76,7 @@ func main() {
7576
monologMod := monolog.New(cfg.MonologAddr)
7677
smtpMod := smtpmod.New(cfg.SMTPAddr, attachments, db)
7778
vardumperMod := vardumper.New(cfg.VarDumperAddr)
79+
proxyMod := proxy.New(cfg.ProxyAddr)
7880

7981
// Start VarDumper PHP parser (only if enabled).
8082
if enabled.IsEnabled("var-dump") {
@@ -110,6 +112,9 @@ func main() {
110112
if enabled.IsEnabled("sms") {
111113
registry.Register(sms.New())
112114
}
115+
// HTTP proxy — stores events as http-dump with response data.
116+
registry.Register(proxyMod)
117+
113118
// Register webhooks module if any webhooks are configured.
114119
if len(cfg.Webhooks) > 0 {
115120
whConfigs := make([]webhooks.WebhookConfig, len(cfg.Webhooks))
@@ -149,6 +154,7 @@ func main() {
149154
if enabled.IsEnabled("var-dump") {
150155
vardumperMod.SetEventService(eventService)
151156
}
157+
proxyMod.SetEventService(eventService)
152158

153159
application := app.New(cfg, db, registry, hub, store, attachments, collector)
154160
application.Run()

docker-compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ services:
5454
volumes:
5555
- ../examples/app:/app/app:ro
5656
- ../examples/routes:/app/routes:ro
57+
- ../examples/resources:/app/resources:ro
5758
- ../examples/config/sentry.php:/app/config/sentry.php:ro
5859
networks:
5960
- buggregator

internal/app/config.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Config struct {
4343
SMTPAddr string `yaml:"-"`
4444
MonologAddr string `yaml:"-"`
4545
VarDumperAddr string `yaml:"-"`
46+
ProxyAddr string `yaml:"-"`
4647
}
4748

4849
// MCPConfig controls the MCP (Model Context Protocol) server.
@@ -88,6 +89,7 @@ type TCPConfig struct {
8889
SMTP TCPServerConfig `yaml:"smtp"`
8990
Monolog TCPServerConfig `yaml:"monolog"`
9091
VarDumper TCPServerConfig `yaml:"var-dumper"`
92+
Proxy TCPServerConfig `yaml:"proxy"`
9193
}
9294

9395
type TCPServerConfig struct {
@@ -178,6 +180,7 @@ func LoadConfig() Config {
178180
cfg.TCP.SMTP.Addr = coalesce(cfg.TCP.SMTP.Addr, os.Getenv("SMTP_ADDR"), fileCfg.TCP.SMTP.Addr, ":1025")
179181
cfg.TCP.Monolog.Addr = coalesce(cfg.TCP.Monolog.Addr, os.Getenv("MONOLOG_ADDR"), fileCfg.TCP.Monolog.Addr, ":9913")
180182
cfg.TCP.VarDumper.Addr = coalesce(cfg.TCP.VarDumper.Addr, os.Getenv("VAR_DUMPER_ADDR"), fileCfg.TCP.VarDumper.Addr, ":9912")
183+
cfg.TCP.Proxy.Addr = coalesce(os.Getenv("PROXY_ADDR"), fileCfg.TCP.Proxy.Addr, ":8080")
181184

182185
// Storage.
183186
cfg.Storage.Mode = coalesce(os.Getenv("STORAGE_MODE"), fileCfg.Storage.Mode, "memory")
@@ -231,6 +234,7 @@ func LoadConfig() Config {
231234
cfg.SMTPAddr = cfg.TCP.SMTP.Addr
232235
cfg.MonologAddr = cfg.TCP.Monolog.Addr
233236
cfg.VarDumperAddr = cfg.TCP.VarDumper.Addr
237+
cfg.ProxyAddr = cfg.TCP.Proxy.Addr
234238

235239
return cfg
236240
}
@@ -324,7 +328,7 @@ func coalesce(values ...string) string {
324328
}
325329

326330
func (c Config) String() string {
327-
return fmt.Sprintf("http=%s db=%s smtp=%s monolog=%s vardumper=%s modules=%v",
331+
return fmt.Sprintf("http=%s db=%s smtp=%s monolog=%s vardumper=%s proxy=%s modules=%v",
328332
c.Server.Addr, c.Database.DSN, c.TCP.SMTP.Addr, c.TCP.Monolog.Addr, c.TCP.VarDumper.Addr,
329-
c.Modules.EnabledTypes())
333+
c.TCP.Proxy.Addr, c.Modules.EnabledTypes())
330334
}

modules/proxy/certgen.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package proxy
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"crypto/x509/pkix"
9+
"math/big"
10+
"net"
11+
"time"
12+
)
13+
14+
// generateCA creates a self-signed CA certificate in memory.
15+
func generateCA() (*tls.Certificate, error) {
16+
key, err := rsa.GenerateKey(rand.Reader, 2048)
17+
if err != nil {
18+
return nil, err
19+
}
20+
21+
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
template := &x509.Certificate{
27+
SerialNumber: serial,
28+
Subject: pkix.Name{
29+
Organization: []string{"Buggregator Proxy CA"},
30+
CommonName: "Buggregator Proxy CA",
31+
},
32+
NotBefore: time.Now().Add(-time.Hour),
33+
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
34+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
35+
BasicConstraintsValid: true,
36+
IsCA: true,
37+
MaxPathLen: 1,
38+
}
39+
40+
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
41+
if err != nil {
42+
return nil, err
43+
}
44+
45+
cert, err := x509.ParseCertificate(certDER)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
return &tls.Certificate{
51+
Certificate: [][]byte{certDER},
52+
PrivateKey: key,
53+
Leaf: cert,
54+
}, nil
55+
}
56+
57+
// generateHostCert creates a TLS certificate for the given host, signed by the CA.
58+
func generateHostCert(ca *tls.Certificate, host string) (*tls.Certificate, error) {
59+
key, err := rsa.GenerateKey(rand.Reader, 2048)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
template := &x509.Certificate{
70+
SerialNumber: serial,
71+
Subject: pkix.Name{
72+
CommonName: host,
73+
},
74+
NotBefore: time.Now().Add(-time.Hour),
75+
NotAfter: time.Now().Add(365 * 24 * time.Hour),
76+
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
77+
ExtKeyUsage: []x509.ExtKeyUsage{
78+
x509.ExtKeyUsageServerAuth,
79+
},
80+
}
81+
82+
if ip := net.ParseIP(host); ip != nil {
83+
template.IPAddresses = []net.IP{ip}
84+
} else {
85+
template.DNSNames = []string{host}
86+
}
87+
88+
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.Leaf, &key.PublicKey, ca.PrivateKey)
89+
if err != nil {
90+
return nil, err
91+
}
92+
93+
return &tls.Certificate{
94+
Certificate: [][]byte{certDER},
95+
PrivateKey: key,
96+
}, nil
97+
}

modules/proxy/module.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package proxy
2+
3+
import (
4+
"context"
5+
6+
"github.com/buggregator/go-buggregator/internal/event"
7+
"github.com/buggregator/go-buggregator/internal/module"
8+
"github.com/buggregator/go-buggregator/internal/server/tcp"
9+
)
10+
11+
// Module implements an HTTP forward proxy that captures request/response pairs
12+
// and stores them as "http-dump" events with an additional response and proxy flag.
13+
type Module struct {
14+
module.BaseModule
15+
addr string
16+
eventService EventStorer
17+
}
18+
19+
type EventStorer interface {
20+
HandleIncoming(ctx context.Context, inc *event.Incoming) error
21+
}
22+
23+
func New(addr string) *Module {
24+
return &Module{addr: addr}
25+
}
26+
27+
func (m *Module) SetEventService(es EventStorer) {
28+
m.eventService = es
29+
}
30+
31+
func (m *Module) Name() string { return "HTTP Proxy" }
32+
func (m *Module) Type() string { return "http-dump" }
33+
34+
func (m *Module) TCPServers() []tcp.ServerConfig {
35+
if m.eventService == nil {
36+
return nil
37+
}
38+
return []tcp.ServerConfig{
39+
{
40+
Name: "http-proxy",
41+
Address: m.addr,
42+
Starter: newProxyServer(m.addr, m.eventService),
43+
},
44+
}
45+
}

modules/proxy/payload.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package proxy
2+
3+
import (
4+
"encoding/json"
5+
"time"
6+
7+
"github.com/buggregator/go-buggregator/internal/event"
8+
)
9+
10+
const maxBodySize = 512 * 1024 // 512KB
11+
12+
// buildHTTPDumpEvent creates an http-dump event from a proxied request/response pair.
13+
// The payload matches the http-dump format with additional "response" and "proxy" fields.
14+
func buildHTTPDumpEvent(req *capturedRequest, resp *capturedResponse, durationMs float64, proxyErr string) *event.Incoming {
15+
headers := make(map[string][]string)
16+
for k, v := range req.Headers {
17+
headers[k] = v
18+
}
19+
20+
query := make(map[string]any)
21+
for k, v := range req.Query {
22+
if len(v) == 1 {
23+
query[k] = v[0]
24+
} else {
25+
query[k] = v
26+
}
27+
}
28+
29+
payload := map[string]any{
30+
"received_at": time.Now().Format("2006-01-02 15:04:05"),
31+
"host": req.Host,
32+
"proxy": true,
33+
"duration_ms": durationMs,
34+
"request": map[string]any{
35+
"method": req.Method,
36+
"uri": req.URI,
37+
"headers": headers,
38+
"body": truncateBody(req.Body),
39+
"query": query,
40+
"post": map[string]any{},
41+
"cookies": map[string]string{},
42+
"files": []any{},
43+
},
44+
}
45+
46+
if proxyErr != "" {
47+
payload["error"] = proxyErr
48+
}
49+
50+
if resp != nil {
51+
respHeaders := make(map[string][]string)
52+
for k, v := range resp.Headers {
53+
respHeaders[k] = v
54+
}
55+
payload["response"] = map[string]any{
56+
"status_code": resp.StatusCode,
57+
"headers": respHeaders,
58+
"body": truncateBody(resp.Body),
59+
}
60+
}
61+
62+
b, _ := json.Marshal(payload)
63+
64+
return &event.Incoming{
65+
UUID: event.GenerateUUID(),
66+
Type: "http-dump",
67+
Payload: json.RawMessage(b),
68+
}
69+
}
70+
71+
type capturedRequest struct {
72+
Method string
73+
URI string
74+
Host string
75+
Scheme string
76+
Headers map[string][]string
77+
Query map[string][]string
78+
Body []byte
79+
}
80+
81+
type capturedResponse struct {
82+
StatusCode int
83+
Headers map[string][]string
84+
Body []byte
85+
}
86+
87+
// truncateBody caps body at maxBodySize and marks it as truncated.
88+
func truncateBody(body []byte) string {
89+
if len(body) <= maxBodySize {
90+
return string(body)
91+
}
92+
return string(body[:maxBodySize]) + "\n[truncated]"
93+
}

0 commit comments

Comments
 (0)