srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-01-16 16:26:00 +0200
committersrdusr <[email protected]>2024-01-16 16:26:00 +0200
commit1125afc47b9d6e68d95d0ffdbb74514f4618e624 (patch)
tree63d5150b7dbc74685ebaec35ee0e74ec30c721d5 /internal
downloadmitmux-1125afc47b9d6e68d95d0ffdbb74514f4618e624.tar.gz
mitmux-1125afc47b9d6e68d95d0ffdbb74514f4618e624.zip
Scaffold mitmux: proxy daemon, CA generation, HTTP/CONNECT passthrough
Implements build-order step 1: headless proxy daemon (mitmuxd) with plaintext HTTP passthrough and raw CONNECT tunneling, plus root CA generation/persistence for later TLS interception. Verified live against real HTTP and HTTPS requests through the proxy.
Diffstat (limited to 'internal')
-rw-r--r--internal/ca/ca.go153
-rw-r--r--internal/proxy/proxy.go153
2 files changed, 306 insertions, 0 deletions
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)
+ }
+}