srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/mitmuxd/main.go4
-rw-r--r--go.mod5
-rw-r--r--go.sum4
-rw-r--r--internal/ca/ca.go85
-rw-r--r--internal/proxy/proxy.go183
5 files changed, 250 insertions, 31 deletions
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go
index 400ca82..f79f2cf 100644
--- a/cmd/mitmuxd/main.go
+++ b/cmd/mitmuxd/main.go
@@ -34,9 +34,9 @@ func main() {
if err != nil {
log.Fatalf("load CA: %v", err)
}
- log.Printf("CA ready: %s", root.Cert.Subject.CommonName)
+ log.Printf("CA ready: %s (install %s/ca.pem in your client's trust store to avoid TLS warnings)", root.Cert.Subject.CommonName, dir)
- srv := proxy.New(*listen)
+ srv := proxy.New(*listen, root)
errCh := make(chan error, 1)
go func() {
diff --git a/go.mod b/go.mod
index 030c225..5b564a3 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,8 @@
module mitmux
go 1.26.5
+
+require (
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..cc9727d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
diff --git a/internal/ca/ca.go b/internal/ca/ca.go
index 949c2fd..fc227a5 100644
--- a/internal/ca/ca.go
+++ b/internal/ca/ca.go
@@ -1,20 +1,23 @@
-// 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 manages mitmux's root CA: generating it on first run,
+// loading it on subsequent runs, and signing per-host leaf certificates
+// on demand for TLS interception.
package ca
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
+ "crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
+ "net"
"os"
"path/filepath"
+ "sync"
"time"
)
@@ -30,6 +33,82 @@ type CA struct {
Key *ecdsa.PrivateKey
CertPEM []byte
KeyPEM []byte
+
+ leafMu sync.Mutex
+ leafCache map[string]*tls.Certificate
+}
+
+// leafLifetime is kept well under the ~398 day limit modern browsers
+// enforce on leaf certificates.
+const leafLifetime = 300 * 24 * time.Hour
+
+// LeafFor returns a TLS certificate for host (a DNS name or IP address,
+// no port), signed by the CA. Certificates are generated once and cached
+// in memory for the life of the process.
+func (c *CA) LeafFor(host string) (*tls.Certificate, error) {
+ c.leafMu.Lock()
+ defer c.leafMu.Unlock()
+
+ if c.leafCache == nil {
+ c.leafCache = make(map[string]*tls.Certificate)
+ }
+ if cert, ok := c.leafCache[host]; ok {
+ return cert, nil
+ }
+
+ cert, err := c.signLeaf(host)
+ if err != nil {
+ return nil, err
+ }
+ c.leafCache[host] = cert
+ return cert, nil
+}
+
+func (c *CA) signLeaf(host string) (*tls.Certificate, 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: host,
+ Organization: []string{"mitmux"},
+ },
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(leafLifetime),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ IsCA: false,
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ tmpl.IPAddresses = []net.IP{ip}
+ } else {
+ tmpl.DNSNames = []string{host}
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, &key.PublicKey, c.Key)
+ if err != nil {
+ return nil, err
+ }
+
+ leaf, err := x509.ParseCertificate(der)
+ if err != nil {
+ return nil, err
+ }
+
+ return &tls.Certificate{
+ Certificate: [][]byte{der, c.Cert.Raw},
+ PrivateKey: key,
+ Leaf: leaf,
+ }, nil
}
// Dir returns the directory mitmux stores its CA material in
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index c9b6195..4fe9a4f 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -1,16 +1,26 @@
-// 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 is the mitmux proxy engine: a forward HTTP proxy.
+// Plain HTTP requests pass through unmodified. CONNECT requests (HTTPS)
+// are intercepted: mitmux terminates TLS with the client using a leaf
+// certificate signed by its own CA, and separately terminates TLS with
+// the real server, forwarding requests between the two. ALPN is
+// negotiated with the real server first and mirrored to the client so
+// HTTP/2 connections stay HTTP/2 end to end rather than being downgraded.
package proxy
import (
"context"
+ "crypto/tls"
+ "errors"
"io"
"log"
"net"
"net/http"
+ "sync"
"time"
+
+ "golang.org/x/net/http2"
+
+ "mitmux/internal/ca"
)
// hopByHopHeaders are stripped before forwarding a request or response,
@@ -32,14 +42,17 @@ var hopByHopHeaders = []string{
type Server struct {
Addr string
+ ca *ca.CA
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 {
+// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"),
+// signing intercepted TLS connections with root.
+func New(addr string, root *ca.CA) *Server {
s := &Server{
Addr: addr,
+ ca: root,
transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
@@ -78,43 +91,161 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
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.
+// handleConnect intercepts a CONNECT request: it terminates TLS with the
+// client using a leaf certificate signed by mitmux's CA, then forwards
+// each request upstream over its own independently negotiated TLS
+// connection. Client-side and upstream-side ALPN are negotiated
+// separately (each offering both HTTP/2 and HTTP/1.1) rather than one
+// being forced to match the other, so e.g. an HTTP/1.1-only client
+// reaching an HTTP/2-only-preferring server doesn't fail to connect -
+// http.Transport (via http2.ConfigureTransport) bridges the two sides
+// independently per request.
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
- dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
+ hostPort := r.Host
+ hostname, _, err := net.SplitHostPort(hostPort)
if err != nil {
- http.Error(w, err.Error(), http.StatusBadGateway)
- return
+ hostname = hostPort
+ hostPort = net.JoinHostPort(hostPort, "443")
}
- defer dst.Close()
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
- src, _, err := hijacker.Hijack()
+ client, _, 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 {
+ if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
+ client.Close()
+ return
+ }
+
+ clientTLS := tls.Server(client, &tls.Config{
+ GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ name := hello.ServerName
+ if name == "" {
+ name = hostname
+ }
+ return s.ca.LeafFor(name)
+ },
+ NextProtos: []string{http2.NextProtoTLS, "http/1.1"},
+ MinVersion: tls.VersionTLS12,
+ })
+ if err := clientTLS.Handshake(); err != nil {
+ log.Printf("mitm handshake with client for %s: %v", hostname, err)
+ client.Close()
+ return
+ }
+
+ tr := &http.Transport{
+ DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ return dialUpstreamTLS(ctx, hostPort, hostname)
+ },
+ }
+ if err := http2.ConfigureTransport(tr); err != nil {
+ log.Printf("configure h2 transport for %s: %v", hostname, err)
+ }
+ defer tr.CloseIdleConnections()
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.forward(tr, "https", hostname, w, r)
+ })
+
+ if clientTLS.ConnectionState().NegotiatedProtocol == http2.NextProtoTLS {
+ (&http2.Server{}).ServeConn(clientTLS, &http2.ServeConnOpts{Handler: handler})
return
}
- done := make(chan struct{}, 2)
- go func() {
- io.Copy(dst, src)
- done <- struct{}{}
- }()
- go func() {
- io.Copy(src, dst)
- done <- struct{}{}
- }()
- <-done
+ err = http.Serve(newSingleConnListener(clientTLS), handler)
+ if err != nil && !errors.Is(err, io.EOF) {
+ log.Printf("h1 serve for %s: %v", hostname, err)
+ }
+}
+
+// dialUpstreamTLS connects to the real server, offering both HTTP/2 and
+// HTTP/1.1 over ALPN and letting the server pick.
+func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (*tls.Conn, error) {
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ raw, err := dialer.DialContext(ctx, "tcp", hostPort)
+ if err != nil {
+ return nil, err
+ }
+ conn := tls.Client(raw, &tls.Config{
+ ServerName: sni,
+ NextProtos: []string{http2.NextProtoTLS, "http/1.1"},
+ })
+ if err := conn.HandshakeContext(ctx); err != nil {
+ raw.Close()
+ return nil, err
+ }
+ return conn, nil
+}
+
+// forward sends r upstream via rt and copies the response back to w,
+// rewriting r's URL from origin-form (as read off the terminated TLS
+// connection) to absolute-form for the round trip.
+func (s *Server) forward(rt http.RoundTripper, scheme, hostname string, w http.ResponseWriter, r *http.Request) {
+ outReq := r.Clone(r.Context())
+ outReq.URL.Scheme = scheme
+ outReq.URL.Host = hostname
+ outReq.RequestURI = ""
+ stripHopByHop(outReq.Header)
+
+ resp, err := rt.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)
+}
+
+// singleConnListener adapts one already-accepted net.Conn into a
+// net.Listener so http.Serve can drive it, returning io.EOF from the
+// second Accept once the connection closes.
+type singleConnListener struct {
+ ch chan net.Conn
+ addr net.Addr
+}
+
+func newSingleConnListener(c net.Conn) *singleConnListener {
+ ch := make(chan net.Conn, 1)
+ ch <- &closeSignalConn{Conn: c, onClose: sync.OnceFunc(func() { close(ch) })}
+ return &singleConnListener{ch: ch, addr: c.LocalAddr()}
+}
+
+func (l *singleConnListener) Accept() (net.Conn, error) {
+ c, ok := <-l.ch
+ if !ok {
+ return nil, io.EOF
+ }
+ return c, nil
+}
+
+func (l *singleConnListener) Close() error { return nil }
+func (l *singleConnListener) Addr() net.Addr { return l.addr }
+
+type closeSignalConn struct {
+ net.Conn
+ onClose func()
+}
+
+func (c *closeSignalConn) Close() error {
+ err := c.Conn.Close()
+ c.onClose()
+ return err
}
// handleHTTP forwards a plain (non-CONNECT) proxy request and copies the