srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--PLAN.md48
-rw-r--r--cmd/mitmuxd/main.go62
-rw-r--r--go.mod3
-rw-r--r--internal/ca/ca.go153
-rw-r--r--internal/proxy/proxy.go153
6 files changed, 424 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1915ed4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/bin/
+*.pem
+*.db
+*.db-wal
+*.db-shm
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..1393282
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,48 @@
+# mitmux - Intercepting Proxy TUI (Burp/Caido replacement)
+
+## Overview
+Daily-driver intercepting proxy for manual pentest work, terminal-based.
+Prior art to read before writing code: Cruster (Rust, built on
+hudsucker) - same problem, worth studying even though this build is Go.
+
+## Stack
+- Language: Go - memory safety on hostile input matters here more than
+ in the other projects, since this parses attacker-adjacent traffic
+- TLS interception: Go's own `crypto/tls` + a CA cert generator
+ (analogous to `rcgen`) for per-domain leaf certs
+- Proxy core: `net/http` + manual `CONNECT` handling, or a MITM proxy
+ library if one fits without fighting Go's aggressive header
+ normalization
+- Storage: SQLite in WAL mode - blob columns for raw request/response
+ bytes, FTS5 index for search across bodies
+- UI: Bubble Tea + Lipgloss (TUI), same family as the packet analyzer's
+ Go sibling if that ever gets built
+
+## Architecture sketch (important - don't skip this)
+- Split proxy engine from TUI. Headless daemon owns the listening
+ socket and the DB; TUI is a client over a Unix socket. The proxy
+ keeps running when the UI restarts, and a web UI or CLI scanner can
+ be bolted on later without touching the engine.
+- Store raw bytes as the source of truth. Parse into a display view,
+ never re-serialize for storage - request smuggling, header injection,
+ and parser-differential bugs depend on the original malformed framing
+ surviving. For Repeater specifically, write requests as raw bytes
+ over the socket rather than through a normalizing HTTP client.
+
+## Build order
+1. Proxy + CA cert generation + plaintext HTTP passthrough
+2. TLS interception (per-host cert generation, install CA)
+3. History view (SQLite storage, raw bytes preserved) in the TUI
+4. Repeater (raw-byte send/resend, the feature used daily)
+5. Search/filter (FTS5)
+6. Match-and-replace rules
+7. Intruder-equivalent (last, optional)
+
+## Open questions
+- HTTP/2: handle natively (decided) - full fidelity over MITM'd
+ connections rather than downgrading to HTTP/1.1. Adds complexity to
+ CONNECT handling, stream framing, and step 3 storage (multiplexed
+ streams over one connection need per-stream request/response
+ boundaries, not just per-connection ones).
+- CA install UX per OS (Linux/macOS/Windows trust stores)
+- Whether WebSocket interception is v1 or a later addition
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go
new file mode 100644
index 0000000..400ca82
--- /dev/null
+++ b/cmd/mitmuxd/main.go
@@ -0,0 +1,62 @@
+// Command mitmuxd is the mitmux headless proxy daemon. It owns the
+// listening socket and (in later build steps) the traffic database; a
+// TUI or other client attaches separately without interrupting capture.
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "mitmux/internal/ca"
+ "mitmux/internal/proxy"
+)
+
+func main() {
+ listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address")
+ caDir := flag.String("ca-dir", "", "directory for CA cert/key (default: XDG config dir)")
+ flag.Parse()
+
+ dir := *caDir
+ if dir == "" {
+ d, err := ca.Dir()
+ if err != nil {
+ log.Fatalf("resolve CA dir: %v", err)
+ }
+ dir = d
+ }
+
+ root, err := ca.EnsureCA(dir)
+ if err != nil {
+ log.Fatalf("load CA: %v", err)
+ }
+ log.Printf("CA ready: %s", root.Cert.Subject.CommonName)
+
+ srv := proxy.New(*listen)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- srv.ListenAndServe()
+ }()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+
+ select {
+ case err := <-errCh:
+ if err != nil {
+ log.Fatalf("proxy: %v", err)
+ }
+ case sig := <-sigCh:
+ log.Printf("received %s, shutting down", sig)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Fatalf("shutdown: %v", err)
+ }
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..030c225
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module mitmux
+
+go 1.26.5
diff --git a/internal/ca/ca.go b/internal/ca/ca.go
new file mode 100644
index 0000000..949c2fd
--- /dev/null
+++ b/internal/ca/ca.go
@@ -0,0 +1,153 @@
+// Package ca manages mitmux's root CA: generating it on first run and
+// loading it on subsequent runs. Leaf certificate signing for TLS
+// interception is added in a later build step.
+package ca
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ certFileName = "ca.pem"
+ keyFileName = "ca-key.pem"
+)
+
+// CA holds the root certificate and key used to sign per-host leaf
+// certificates during TLS interception.
+type CA struct {
+ Cert *x509.Certificate
+ Key *ecdsa.PrivateKey
+ CertPEM []byte
+ KeyPEM []byte
+}
+
+// Dir returns the directory mitmux stores its CA material in
+// (XDG config dir, e.g. ~/.config/mitmux).
+func Dir() (string, error) {
+ cfg, err := os.UserConfigDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve config dir: %w", err)
+ }
+ return filepath.Join(cfg, "mitmux"), nil
+}
+
+// EnsureCA loads the CA from dir, generating and persisting a new one if
+// none exists yet.
+func EnsureCA(dir string) (*CA, error) {
+ certPath := filepath.Join(dir, certFileName)
+ keyPath := filepath.Join(dir, keyFileName)
+
+ certPEM, certErr := os.ReadFile(certPath)
+ keyPEM, keyErr := os.ReadFile(keyPath)
+ if certErr == nil && keyErr == nil {
+ return load(certPEM, keyPEM)
+ }
+ if !errors.Is(certErr, os.ErrNotExist) && certErr != nil {
+ return nil, fmt.Errorf("read %s: %w", certPath, certErr)
+ }
+ if !errors.Is(keyErr, os.ErrNotExist) && keyErr != nil {
+ return nil, fmt.Errorf("read %s: %w", keyPath, keyErr)
+ }
+
+ ca, err := generate()
+ if err != nil {
+ return nil, fmt.Errorf("generate CA: %w", err)
+ }
+ if err := persist(dir, ca); err != nil {
+ return nil, fmt.Errorf("persist CA: %w", err)
+ }
+ return ca, nil
+}
+
+func generate() (*CA, error) {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ 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
+ }
+
+ tmpl := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{
+ CommonName: "mitmux local CA",
+ Organization: []string{"mitmux"},
+ },
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().AddDate(10, 0, 0),
+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign,
+ BasicConstraintsValid: true,
+ IsCA: true,
+ MaxPathLenZero: true,
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ return nil, err
+ }
+ cert, err := x509.ParseCertificate(der)
+ if err != nil {
+ return nil, err
+ }
+
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+
+ return &CA{
+ Cert: cert,
+ Key: key,
+ CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
+ KeyPEM: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
+ }, nil
+}
+
+func load(certPEM, keyPEM []byte) (*CA, error) {
+ certBlock, _ := pem.Decode(certPEM)
+ if certBlock == nil {
+ return nil, errors.New("no PEM block found in CA certificate")
+ }
+ cert, err := x509.ParseCertificate(certBlock.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parse CA certificate: %w", err)
+ }
+
+ keyBlock, _ := pem.Decode(keyPEM)
+ if keyBlock == nil {
+ return nil, errors.New("no PEM block found in CA key")
+ }
+ key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parse CA key: %w", err)
+ }
+
+ return &CA{Cert: cert, Key: key, CertPEM: certPEM, KeyPEM: keyPEM}, nil
+}
+
+func persist(dir string, ca *CA) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(dir, certFileName), ca.CertPEM, 0o644); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(dir, keyFileName), ca.KeyPEM, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
new file mode 100644
index 0000000..c9b6195
--- /dev/null
+++ b/internal/proxy/proxy.go
@@ -0,0 +1,153 @@
+// Package proxy is the mitmux proxy engine: a forward HTTP proxy that,
+// at this build stage, passes traffic through unmodified. CONNECT
+// requests (HTTPS) are tunneled raw rather than intercepted - TLS
+// interception is a later build step.
+package proxy
+
+import (
+ "context"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "time"
+)
+
+// hopByHopHeaders are stripped before forwarding a request or response,
+// per RFC 7230 6.1 - they are meaningful only between a client and its
+// immediate next hop, not end-to-end.
+var hopByHopHeaders = []string{
+ "Connection",
+ "Proxy-Connection",
+ "Keep-Alive",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "TE",
+ "Trailers",
+ "Transfer-Encoding",
+ "Upgrade",
+}
+
+// Server is a forward proxy listener.
+type Server struct {
+ Addr string
+
+ transport *http.Transport
+ server *http.Server
+}
+
+// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080").
+func New(addr string) *Server {
+ s := &Server{
+ Addr: addr,
+ transport: &http.Transport{
+ Proxy: nil,
+ DialContext: (&net.Dialer{
+ Timeout: 10 * time.Second,
+ }).DialContext,
+ ForceAttemptHTTP2: false,
+ MaxIdleConns: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ },
+ }
+ s.server = &http.Server{
+ Addr: addr,
+ Handler: http.HandlerFunc(s.handle),
+ }
+ return s
+}
+
+// ListenAndServe starts the proxy and blocks until it stops.
+func (s *Server) ListenAndServe() error {
+ log.Printf("proxy listening on %s", s.Addr)
+ return s.server.ListenAndServe()
+}
+
+// Shutdown gracefully stops the proxy.
+func (s *Server) Shutdown(ctx context.Context) error {
+ return s.server.Shutdown(ctx)
+}
+
+func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodConnect {
+ s.handleConnect(w, r)
+ return
+ }
+ s.handleHTTP(w, r)
+}
+
+// handleConnect tunnels a CONNECT request raw, byte for byte, without
+// terminating TLS. This is the passthrough behavior for HTTPS traffic
+// until TLS interception is implemented.
+func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
+ dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ defer dst.Close()
+
+ hijacker, ok := w.(http.Hijacker)
+ if !ok {
+ http.Error(w, "hijacking not supported", http.StatusInternalServerError)
+ return
+ }
+ src, _, err := hijacker.Hijack()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer src.Close()
+
+ if _, err := src.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
+ return
+ }
+
+ done := make(chan struct{}, 2)
+ go func() {
+ io.Copy(dst, src)
+ done <- struct{}{}
+ }()
+ go func() {
+ io.Copy(src, dst)
+ done <- struct{}{}
+ }()
+ <-done
+}
+
+// handleHTTP forwards a plain (non-CONNECT) proxy request and copies the
+// response back unmodified.
+func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
+ if !r.URL.IsAbs() {
+ http.Error(w, "mitmux: request must use absolute-form URI (configure as a proxy, not a target)", http.StatusBadRequest)
+ return
+ }
+
+ outReq := r.Clone(r.Context())
+ outReq.RequestURI = ""
+ stripHopByHop(outReq.Header)
+
+ resp, err := s.transport.RoundTrip(outReq)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+
+ stripHopByHop(resp.Header)
+ for k, vv := range resp.Header {
+ for _, v := range vv {
+ w.Header().Add(k, v)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ io.Copy(w, resp.Body)
+}
+
+func stripHopByHop(h http.Header) {
+ for _, k := range hopByHopHeaders {
+ h.Del(k)
+ }
+}