srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-08 16:11:00 +0200
committersrdusr <[email protected]>2026-04-08 16:11:00 +0200
commit7b9bf73e46102969d5802300469862296f979ef6 (patch)
treef38ac2bc2f686989bee86f4bd273f2f9127f4475
parentf77a9570e973dda7247c653754e62d5a9a895658 (diff)
downloadmitmux-7b9bf73e46102969d5802300469862296f979ef6.tar.gz
mitmux-7b9bf73e46102969d5802300469862296f979ef6.zip
Multiple proxy listeners and upstream proxy chaining
Closes the last two items from the original "worth considering" list. Multiple listeners: -listen takes a comma-separated address list (-listen "127.0.0.1:8080,127.0.0.1:8081"). Server.Addr became Server.Addrs; ListenAndServe binds every address up front - before any of them start serving - so a bad address fails startup immediately rather than leaving the daemon partially listening, and rolls back already-opened listeners if a later one fails to bind. All addresses share the same handler/history/CA/rules: one logical proxy reachable on more than one address, not several independent proxies in one process. Upstream proxy chaining: -upstream-proxy host:port (optional http:// prefix, stripped for convenience) routes every outbound connection through another HTTP CONNECT proxy instead of dialing origins directly. dialViaProxy does the CONNECT handshake to the upstream and hands back a plain net.Conn as if it were a direct connection; dialUpstreamTLS (CONNECT/HTTPS path) and dialUpstreamPlain (plain-HTTP path) both take an upstreamProxy parameter and route through it when set. The two paths need different handling: CONNECT/HTTPS is transparent below the tunnel (once the CONNECT handshake succeeds, TLS and the request on top of it look identical to a direct connection, so roundTripH2 and the H1 read side need no changes at all), but plain HTTP has to send an absolute-form request line to the upstream proxy instead of origin-form - so roundTripH1 gained a proxyForm parameter, and forward() selects it based on scheme=="http" && UpstreamProxy!="". Chaining into another intercepting/MITM proxy (including another mitmuxd) needs that proxy's own CA trusted too, or TLS verification fails - this is inherent to chaining MITM proxies, not a gap here, and confirmed live below rather than left as a guess. internal/proxy/dialer_test.go: dialViaProxy against a real local CONNECT stub (not a mock) - direct dial, successful tunnel-and-echo through a proxy, and a proxy that refuses the CONNECT with a non-200. All three exercise the actual network code path, not just the string-building around it. Verified live: started a daemon with two -listen addresses, sent requests through both, confirmed a single shared history; killed it mid-flight with SIGTERM and confirmed both listeners closed cleanly; started it with one bad address in the list and confirmed startup failed immediately with the already-bound port released, no lingering process. For chaining: sent plain HTTP and HTTPS through a downstream mitmuxd configured with -upstream-proxy pointing at a genuine passthrough CONNECT stub (tunnels raw bytes, doesn't MITM) and got real content back on both; separately chained through a second mitmuxd instance and got the expected "certificate signed by unknown authority" error, cleanly recorded in history rather than hanging. go build/vet/gofmt/test/mod tidy all clean.
-rw-r--r--PLAN.md40
-rw-r--r--README.md19
-rw-r--r--cmd/mitmuxd/main.go22
-rw-r--r--internal/proxy/dialer_test.go139
-rw-r--r--internal/proxy/proxy.go174
5 files changed, 359 insertions, 35 deletions
diff --git a/PLAN.md b/PLAN.md
index 28b80e3..edaf599 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -167,8 +167,44 @@ to test against; only the command text itself (sourced from each
platform's standard, documented tooling) is confirmed correct by
inspection.
-Still open from "worth considering": multiple proxy listeners and
-upstream proxy chaining. Not started yet.
+Shipped since: multiple proxy listeners and upstream proxy chaining -
+the last two items from the original "worth considering" list.
+
+Multiple listeners: `-listen` takes a comma-separated address list
+(`-listen "127.0.0.1:8080,127.0.0.1:8081"`); all bound addresses share
+the same handler, history store, CA and rules - one logical proxy
+reachable on more than one address/port, not several independent
+proxies in one process. Every address is bound up front before any of
+them start serving, so a bad address fails startup immediately instead
+of leaving the daemon partially listening.
+
+Upstream proxy chaining: `-upstream-proxy host:port` (optional
+`http://` prefix, stripped) routes every outbound connection through
+another HTTP CONNECT proxy instead of dialing origins directly -
+chaining mitmux into Burp, a corporate proxy, or any other
+CONNECT-speaking proxy. SOCKS5 upstreams aren't implemented. For the
+CONNECT/HTTPS path, chaining is transparent below the tunnel: once the
+CONNECT handshake to the upstream proxy succeeds, TLS and the
+request/response on top of it are identical to a direct connection, so
+no other code needed to change. The plain-HTTP path is different: an
+absolute-form request line ("GET http://host/path HTTP/1.1") has to be
+sent to the upstream proxy instead of origin-form, so `roundTripH1`
+gained a `proxyForm` parameter and `forward()` selects it based on
+whether the request is plain HTTP and an upstream proxy is configured.
+
+Chaining into another intercepting/MITM proxy (including another
+mitmuxd instance) will fail TLS verification unless that proxy's own CA
+is separately trusted - expected, not a mitmux-specific gap: the
+upstream MITM terminates and re-signs the connection with its own CA,
+which mitmux's outbound TLS client (verifying against the system root
+store) has no reason to trust. Confirmed live: chaining through a
+genuine passthrough CONNECT proxy (tunnels raw bytes, no MITM) works
+correctly for both plain HTTP and HTTPS; chaining through a second
+mitmuxd instance correctly fails with a clear
+"certificate signed by unknown authority" error recorded in history,
+rather than hanging or crashing.
+
+This closes every item from the original "worth considering" list.
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
diff --git a/README.md b/README.md
index f65e3fe..b880fc3 100644
--- a/README.md
+++ b/README.md
@@ -123,8 +123,21 @@ go build -o bin/mitmux ./cmd/mitmux
```
Both binaries take flags for non-default setups - `-listen`, `-socket`,
-`-ca-dir`, `-db` on `mitmuxd`; `-socket` on `mitmux`. Run either with
-`-h` for the full list.
+`-ca-dir`, `-db`, `-upstream-proxy` on `mitmuxd`; `-socket` on `mitmux`.
+Run either with `-h` for the full list.
+
+`-listen` takes a comma-separated list to bind more than one address
+(`-listen "127.0.0.1:8080,127.0.0.1:8081"`) - one logical proxy on
+several ports/interfaces, sharing the same history, CA and rules.
+
+`-upstream-proxy host:port` chains every outbound connection through
+another HTTP CONNECT proxy (Burp, a corporate proxy, anything that
+speaks CONNECT) instead of dialing origins directly. Chaining into
+another *intercepting* proxy needs that proxy's own CA trusted too -
+it terminates and re-signs the connection with its own CA, which
+mitmux's outbound TLS client has no reason to trust otherwise; you'll
+see a clear certificate-verification error in history rather than a
+silent failure. SOCKS5 upstreams aren't implemented.
## Usage
@@ -330,5 +343,7 @@ reasoning behind each:
never runs them for you (see Quick start above for why)
- No WebSocket interception
- No client (mutual-TLS) certificate support
+- Upstream proxy chaining (`-upstream-proxy`) is HTTP CONNECT only, no
+ SOCKS5
- No active or passive vulnerability scanning, no plugin system - this
is a manual-testing tool, not a scanner
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go
index 9c77516..baaba85 100644
--- a/cmd/mitmuxd/main.go
+++ b/cmd/mitmuxd/main.go
@@ -14,6 +14,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
+ "strings"
"syscall"
"time"
@@ -24,13 +25,25 @@ import (
)
func main() {
- listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address")
+ listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address(es) - comma-separated for more than one, e.g. \"127.0.0.1:8080,127.0.0.1:8081\"")
caDir := flag.String("ca-dir", "", "directory for CA cert/key and history db (default: XDG config dir)")
dbPath := flag.String("db", "", "path to history database (default: <ca-dir>/history.db)")
socketPath := flag.String("socket", "", "control socket path (default: $XDG_RUNTIME_DIR/mitmux.sock, else <ca-dir>/mitmux.sock)")
installCA := flag.Bool("install-ca", false, "generate the CA if needed, print OS-specific trust-store install steps, and exit (doesn't start the proxy)")
+ upstreamProxy := flag.String("upstream-proxy", "", "chain all outbound connections through this HTTP CONNECT proxy (host:port, optional http:// prefix) instead of dialing origins directly")
flag.Parse()
+ var listenAddrs []string
+ for _, a := range strings.Split(*listen, ",") {
+ if a = strings.TrimSpace(a); a != "" {
+ listenAddrs = append(listenAddrs, a)
+ }
+ }
+ if len(listenAddrs) == 0 {
+ log.Fatalf("-listen: no addresses given")
+ }
+ upstream := strings.TrimPrefix(strings.TrimSpace(*upstreamProxy), "http://")
+
dir := *caDir
if dir == "" {
d, err := ca.Dir()
@@ -80,11 +93,14 @@ func main() {
defer os.Remove(sockFile)
log.Printf("control socket: %s", sockFile)
- srv := proxy.New(*listen, root, db)
+ srv := proxy.New(listenAddrs, root, db, upstream)
+ if upstream != "" {
+ log.Printf("chaining outbound connections through upstream proxy %s", upstream)
+ }
hub := ipc.NewHub()
srv.OnEntry = ipc.LogAndBroadcast(hub)
- ipcSrv := ipc.NewServer(db, hub, srv, *listen)
+ ipcSrv := ipc.NewServer(db, hub, srv, strings.Join(listenAddrs, ", "))
go func() {
if err := ipcSrv.Serve(sockLn); err != nil {
log.Printf("control socket: %v", err)
diff --git a/internal/proxy/dialer_test.go b/internal/proxy/dialer_test.go
new file mode 100644
index 0000000..14746c9
--- /dev/null
+++ b/internal/proxy/dialer_test.go
@@ -0,0 +1,139 @@
+package proxy
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "testing"
+ "time"
+)
+
+// startStubConnectProxy runs a minimal HTTP CONNECT proxy for the
+// duration of the test: it accepts one CONNECT request, replies with the
+// given status, and if status is 200 splices the tunnel through to a
+// real dial of the requested host. Returns the proxy's address.
+func startStubConnectProxy(t *testing.T, status int) string {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer c.Close()
+ br := bufio.NewReader(c)
+ req, err := http.ReadRequest(br)
+ if err != nil {
+ return
+ }
+ if req.Method != http.MethodConnect {
+ c.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n"))
+ return
+ }
+ if status != http.StatusOK {
+ c.Write([]byte("HTTP/1.1 403 Forbidden\r\n\r\n"))
+ return
+ }
+ target, err := net.Dial("tcp", req.Host)
+ if err != nil {
+ c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
+ return
+ }
+ defer target.Close()
+ c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
+ done := make(chan struct{}, 2)
+ go func() { io.Copy(target, br); done <- struct{}{} }()
+ go func() { io.Copy(c, target); done <- struct{}{} }()
+ <-done
+ }()
+ return ln.Addr().String()
+}
+
+// startEchoServer runs a TCP server that echoes back whatever it reads,
+// standing in for "the origin" on the far side of a CONNECT tunnel.
+func startEchoServer(t *testing.T) string {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) {
+ defer c.Close()
+ io.Copy(c, c)
+ }(c)
+ }
+ }()
+ return ln.Addr().String()
+}
+
+func TestDialViaProxyDirect(t *testing.T) {
+ echoAddr := startEchoServer(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ conn, err := dialViaProxy(ctx, echoAddr, "")
+ if err != nil {
+ t.Fatalf("dialViaProxy direct: %v", err)
+ }
+ defer conn.Close()
+
+ if _, err := conn.Write([]byte("hello")); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ buf := make([]byte, 5)
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if string(buf) != "hello" {
+ t.Errorf("got %q, want %q", buf, "hello")
+ }
+}
+
+func TestDialViaProxyTunneled(t *testing.T) {
+ echoAddr := startEchoServer(t)
+ proxyAddr := startStubConnectProxy(t, http.StatusOK)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ conn, err := dialViaProxy(ctx, echoAddr, proxyAddr)
+ if err != nil {
+ t.Fatalf("dialViaProxy via proxy: %v", err)
+ }
+ defer conn.Close()
+
+ if _, err := conn.Write([]byte("world")); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ buf := make([]byte, 5)
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if string(buf) != "world" {
+ t.Errorf("got %q, want %q", buf, "world")
+ }
+}
+
+func TestDialViaProxyRejected(t *testing.T) {
+ proxyAddr := startStubConnectProxy(t, http.StatusForbidden)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ _, err := dialViaProxy(ctx, "example.invalid:443", proxyAddr)
+ if err == nil {
+ t.Fatal("expected an error when the upstream proxy refuses CONNECT, got nil")
+ }
+}
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)
}