diff options
Diffstat (limited to 'internal/proxy/proxy.go')
| -rw-r--r-- | internal/proxy/proxy.go | 174 |
1 files changed, 146 insertions, 28 deletions
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 3b2d50d..82328db 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -25,10 +25,12 @@ import ( "context" "crypto/tls" "errors" + "fmt" "io" "log" "net" "net/http" + "net/url" "sync" "time" @@ -58,9 +60,20 @@ var hopByHopHeaders = []string{ "Upgrade", } -// Server is a forward proxy listener. +// Server is a forward proxy listener. It can bind more than one address +// at once (Addrs) - all sharing the same handler, history store, CA and +// rules, so a client on any of them sees identical behavior; this is for +// cases like wanting a separate port per client/network segment, not +// for running logically different proxies in one process. type Server struct { - Addr string + Addrs []string + + // UpstreamProxy, if set (host:port, no scheme), chains every + // outbound connection through another HTTP CONNECT proxy instead of + // dialing origins directly - e.g. routing mitmux's own traffic + // through Burp, a corporate proxy, or a network-access proxy. + // SOCKS5 upstreams aren't implemented (see PLAN.md). + UpstreamProxy string // OnEntry, if set, is called after each request/response pair is // stored, so a daemon can broadcast it to live TUI subscribers. @@ -71,27 +84,50 @@ type Server struct { server *http.Server } -// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"), +// New creates a proxy Server bound to addrs (e.g. ["127.0.0.1:8080"]), // signing intercepted TLS connections with root and recording history to -// db. -func New(addr string, root *ca.CA, db *store.Store) *Server { - s := &Server{Addr: addr, ca: root, store: db} +// db. upstreamProxy chains outbound connections through another HTTP +// CONNECT proxy (host:port, no scheme) instead of dialing origins +// directly; empty disables chaining. +func New(addrs []string, root *ca.CA, db *store.Store, upstreamProxy string) *Server { + s := &Server{Addrs: addrs, ca: root, store: db, UpstreamProxy: upstreamProxy} s.server = &http.Server{ - Addr: addr, Handler: http.HandlerFunc(s.handle), ConnContext: withClientTee, } return s } -// ListenAndServe starts the proxy and blocks until it stops. +// ListenAndServe binds every address in Addrs and blocks until one of +// them stops (including on Shutdown, which closes all of them - every +// Serve call below then returns http.ErrServerClosed). Addresses are all +// bound up front before any of them start serving, so a bad address +// (already in use, unparseable, ...) fails startup immediately rather +// than leaving the daemon partially listening. func (s *Server) ListenAndServe() error { - ln, err := net.Listen("tcp", s.Addr) - if err != nil { - return err + if len(s.Addrs) == 0 { + return errors.New("no listen addresses configured") + } + lns := make([]net.Listener, len(s.Addrs)) + for i, addr := range s.Addrs { + ln, err := net.Listen("tcp", addr) + if err != nil { + for _, opened := range lns[:i] { + opened.Close() + } + return fmt.Errorf("listen on %s: %w", addr, err) + } + lns[i] = ln } - log.Printf("proxy listening on %s", s.Addr) - return s.server.Serve(&teeListener{Listener: ln}) + + errCh := make(chan error, len(lns)) + for i, ln := range lns { + log.Printf("proxy listening on %s", s.Addrs[i]) + go func(ln net.Listener) { + errCh <- s.server.Serve(&teeListener{Listener: ln}) + }(ln) + } + return <-errCh } // Shutdown gracefully stops the proxy. @@ -161,7 +197,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { } dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamTLS(ctx, hostPort, hostname) + return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.forward(dial, "https", hostname, w, r) @@ -179,11 +215,14 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { } } -// 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) (net.Conn, string, error) { - nd := &net.Dialer{Timeout: 10 * time.Second} - raw, err := nd.DialContext(ctx, "tcp", hostPort) +// dialUpstreamTLS connects to the real server (directly, or tunneled +// through upstreamProxy if set - see dialViaProxy), offering both +// HTTP/2 and HTTP/1.1 over ALPN and letting the server pick. Chaining +// through another proxy is transparent to everything from here on: once +// the CONNECT tunnel is up, TLS and the request/response on top of it +// look identical to a direct connection. +func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (net.Conn, string, error) { + raw, err := dialViaProxy(ctx, hostPort, upstreamProxy) if err != nil { return nil, "", err } @@ -199,23 +238,98 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (net.Conn, strin } // dialUpstreamPlain connects to a plain (non-TLS) upstream for the -// non-CONNECT proxy path, which is always HTTP/1.1. -func dialUpstreamPlain(ctx context.Context, host string) (net.Conn, string, error) { +// non-CONNECT proxy path, which is always HTTP/1.1. Unlike the TLS/ +// CONNECT path, chaining here means dialing the upstream proxy's own +// address directly and writing it an absolute-form request (what a +// proxy expects) rather than tunneling - see forward()'s viaProxyForm. +func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Conn, string, error) { if _, _, err := net.SplitHostPort(host); err != nil { host = net.JoinHostPort(host, "80") } + target := host + if upstreamProxy != "" { + target = upstreamProxy + } nd := &net.Dialer{Timeout: 10 * time.Second} - conn, err := nd.DialContext(ctx, "tcp", host) + conn, err := nd.DialContext(ctx, "tcp", target) return conn, "http/1.1", err } +// dialViaProxy returns a raw TCP connection ready to speak TLS to +// hostPort - dialed directly if upstreamProxy is empty, or tunneled +// through upstreamProxy via an HTTP CONNECT request otherwise. +func dialViaProxy(ctx context.Context, hostPort, upstreamProxy string) (net.Conn, error) { + nd := &net.Dialer{Timeout: 10 * time.Second} + if upstreamProxy == "" { + return nd.DialContext(ctx, "tcp", hostPort) + } + + conn, err := nd.DialContext(ctx, "tcp", upstreamProxy) + if err != nil { + return nil, fmt.Errorf("dial upstream proxy %s: %w", upstreamProxy, err) + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: hostPort}, + Host: hostPort, + Header: make(http.Header), + } + if err := connectReq.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write CONNECT to upstream proxy %s: %w", upstreamProxy, err) + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read CONNECT response from upstream proxy %s: %w", upstreamProxy, err) + } + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("upstream proxy %s refused CONNECT to %s: %s", upstreamProxy, hostPort, resp.Status) + } + if br.Buffered() > 0 { + // The upstream proxy shouldn't send anything past the CONNECT + // response before the tunnel starts, but if it did, those bytes + // are sitting in br's buffer, not on conn - replay them first + // rather than silently dropping the start of the TLS handshake. + return &prefixedConn{Conn: conn, r: br}, nil + } + return conn, nil +} + +// prefixedConn serves buffered bytes from r before falling through to +// reading directly off the underlying connection. +type prefixedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *prefixedConn) Read(p []byte) (int, error) { + if c.r.Buffered() > 0 { + return c.r.Read(p) + } + return c.Conn.Read(p) +} + // roundTripH1 writes outReq directly to conn and reads the response back // off the same connection, wrapping conn in a teeConn so the exact wire -// bytes of both can be captured. -func roundTripH1(conn net.Conn, outReq *http.Request) (*http.Response, *teeConn, error) { +// bytes of both can be captured. proxyForm selects an absolute-form +// request line ("GET http://host/path HTTP/1.1") instead of origin-form +// - needed when conn is a connection to another proxy, which expects +// that form, rather than to the origin server itself. +func roundTripH1(conn net.Conn, outReq *http.Request, proxyForm bool) (*http.Response, *teeConn, error) { tee := newTeeConn(conn) - if err := outReq.Write(tee); err != nil { - return nil, nil, err + var writeErr error + if proxyForm { + writeErr = outReq.WriteProxy(tee) + } else { + writeErr = outReq.Write(tee) + } + if writeErr != nil { + return nil, nil, writeErr } resp, err := http.ReadResponse(bufio.NewReader(tee), outReq) if err != nil { @@ -290,7 +404,11 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr if negotiated == http2.NextProtoTLS { resp, err = roundTripH2(conn, outReq) } else { - resp, upstreamTee, err = roundTripH1(conn, outReq) + // Only the plain-HTTP path needs absolute-form: a CONNECT tunnel + // (chained through an upstream proxy or not) is transparent from + // here on, so it always uses origin-form like a direct connection. + proxyForm := scheme == "http" && s.UpstreamProxy != "" + resp, upstreamTee, err = roundTripH1(conn, outReq, proxyForm) } duration := time.Since(started) @@ -446,7 +564,7 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { } host := r.URL.Host dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamPlain(ctx, host) + return dialUpstreamPlain(ctx, host, s.UpstreamProxy) } s.forward(dial, r.URL.Scheme, r.URL.Host, w, r) } |