diff --git a/cmd/proxy/start.go b/cmd/proxy/start.go index 9bb7f77..c6674ed 100644 --- a/cmd/proxy/start.go +++ b/cmd/proxy/start.go @@ -34,6 +34,8 @@ func newStartCommand() *cobra.Command { cmd.Flags().BoolVarP(&daemonFlag, "daemon", "D", false, "Run the proxy as a detached background process") cmd.Flags().StringVar(&srv.ListenHost, "host", srv.ListenHost, "Host to bind") cmd.Flags().IntVar(&srv.ListenPort, "port", srv.ListenPort, "Port to bind (0 = a random free port)") + cmd.Flags().BoolVar(&srv.Transparent, "transparent", srv.Transparent, + "Also accept connections redirected to the proxy, recovering the destination from the TLS SNI") cmd.Flags().StringVar(&logFileFlag, "log-file", "", "File for the daemon's output (default: /proxy.log)") cmd.Flags().BoolVar(&foregroundInternalFlag, "foreground-internal", false, "Internal: run the foreground server (used by --daemon)") if err := cmd.Flags().MarkHidden("foreground-internal"); err != nil { @@ -47,9 +49,10 @@ func runStart(cmd *cobra.Command, _ []string) error { statePath := proxyserver.ResolveStatePath(stateFlag, cfg.CacheDir()) host := cfg.Config.Proxy.Server.ListenHost port := cfg.Config.Proxy.Server.ListenPort + transparent := cfg.Config.Proxy.Server.Transparent if daemonFlag && !foregroundInternalFlag { - if err := startDaemon(cmd, cfg, statePath, host, port); err != nil { + if err := startDaemon(cmd, cfg, statePath, host, port, transparent); err != nil { ui.ErrorExit(err) } return nil @@ -61,7 +64,7 @@ func runStart(cmd *cobra.Command, _ []string) error { return nil } -func startDaemon(cmd *cobra.Command, cfg *config.RuntimeConfig, statePath, host string, port int) error { +func startDaemon(cmd *cobra.Command, cfg *config.RuntimeConfig, statePath, host string, port int, transparent bool) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable: %w", err) @@ -77,7 +80,7 @@ func startDaemon(cmd *cobra.Command, cfg *config.RuntimeConfig, statePath, host return fmt.Errorf("create daemon log dir: %w", err) } - args := daemonArgs(cmd, statePath, host, port) + args := daemonArgs(cmd, statePath, host, port, transparent) daemonCfg := proxyserver.ProxyDaemonConfig{ LogPath: logPath, @@ -92,12 +95,16 @@ func startDaemon(cmd *cobra.Command, cfg *config.RuntimeConfig, statePath, host return werr } -func daemonArgs(cmd *cobra.Command, statePath, host string, port int) []string { +// daemonArgs passes the resolved values explicitly rather than relying on the +// child to re-derive them, since a value supplied by flag is not visible to the +// child's own config load. +func daemonArgs(cmd *cobra.Command, statePath, host string, port int, transparent bool) []string { args := append([]string{}, config.ChangedConfigFlagArgs(cmd)...) return append(args, "proxy", "start", "--foreground-internal", "--state", statePath, "--host", host, "--port", strconv.Itoa(port), + "--transparent="+strconv.FormatBool(transparent), ) } diff --git a/cmd/proxy/start_test.go b/cmd/proxy/start_test.go index 3c8b165..14e38a3 100644 --- a/cmd/proxy/start_test.go +++ b/cmd/proxy/start_test.go @@ -17,7 +17,7 @@ func TestDaemonArgsPrependsChangedConfigFlags(t *testing.T) { start := &cobra.Command{ Use: "start", Run: func(cmd *cobra.Command, _ []string) { - got = daemonArgs(cmd, "/tmp/proxy-state.json", "127.0.0.1", 9000) + got = daemonArgs(cmd, "/tmp/proxy-state.json", "127.0.0.1", 9000, false) }, } proxyCmd := &cobra.Command{Use: "proxy"} @@ -37,5 +37,28 @@ func TestDaemonArgsPrependsChangedConfigFlags(t *testing.T) { "--state", "/tmp/proxy-state.json", "--host", "127.0.0.1", "--port", "9000", + "--transparent=false", }, got) } + +// The daemon re-execs itself, so a transparent value supplied by flag must be +// carried across explicitly. The child's own config load cannot see it. +func TestDaemonArgsCarriesTransparentToChild(t *testing.T) { + root := &cobra.Command{Use: "pmg"} + config.ApplyCobraFlags(root) + + var got []string + start := &cobra.Command{ + Use: "start", + Run: func(cmd *cobra.Command, _ []string) { + got = daemonArgs(cmd, "/tmp/proxy-state.json", "127.0.0.1", 9000, true) + }, + } + proxyCmd := &cobra.Command{Use: "proxy"} + proxyCmd.AddCommand(start) + root.AddCommand(proxyCmd) + root.SetArgs([]string{"proxy", "start"}) + + require.NoError(t, root.Execute()) + assert.Contains(t, got, "--transparent=true") +} diff --git a/config/config.go b/config/config.go index 25417ea..403075e 100644 --- a/config/config.go +++ b/config/config.go @@ -181,6 +181,14 @@ type ProxyServerConfig struct { // ListenPort is the port the persistent proxy binds to. 0 (default) means a // random free port. The --port flag overrides this. ListenPort int `mapstructure:"listen_port"` + + // Transparent additionally accepts connections that were redirected to the + // proxy rather than addressed to it. A redirected client believes it reached + // the real registry, so it speaks TLS immediately instead of sending CONNECT, + // and its destination is recovered from the TLS SNI. Off by default: it is + // only useful alongside a redirect mechanism such as the Linux eBPF + // enforcement layer. The --transparent flag overrides this. + Transparent bool `mapstructure:"transparent"` } // SandboxConfig configures the sandbox system for isolating package manager processes. diff --git a/internal/proxyserver/server.go b/internal/proxyserver/server.go index 0d53216..d076a51 100644 --- a/internal/proxyserver/server.go +++ b/internal/proxyserver/server.go @@ -114,6 +114,7 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string, proxyConfig := pmgproxy.DefaultProxyConfig() proxyConfig.ListenAddr = listenAddr(host, port) + proxyConfig.EnableTransparent = cfg.Config.Proxy.Server.Transparent proxyConfig.CertManager = certMgr proxyConfig.Interceptors = interceptorList presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage} diff --git a/proxy/proxy.go b/proxy/proxy.go index 30d635a..b2f6735 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -72,6 +72,14 @@ type ProxyConfig struct { RequestTimeout time.Duration ConnectTimeout time.Duration + // EnableTransparent accepts redirected connections on the same listener as + // explicit proxy clients. A redirected client (e.g. via an eBPF connect + // rewrite) believes it reached the real registry, so it speaks TLS + // immediately instead of sending CONNECT. Its destination is recovered from + // the ClientHello's SNI. Connections without SNI cannot be routed and are + // dropped. + EnableTransparent bool + // ServerReadWriteTimeout is the timeout applied to the http.Server's // ReadTimeout and WriteTimeout. These deadlines are set on the raw TCP // connection and persist after Hijack(), which means they become the @@ -266,6 +274,10 @@ func (ps *proxyServer) Start() error { return fmt.Errorf("failed to start listener: %w", err) } + if ps.config.EnableTransparent { + listener = newTransparentListener(listener, ps.proxy) + } + ps.listener = listener serverTimeout := ps.config.ServerReadWriteTimeout diff --git a/proxy/transparent.go b/proxy/transparent.go new file mode 100644 index 0000000..38dd28e --- /dev/null +++ b/proxy/transparent.go @@ -0,0 +1,302 @@ +package proxy + +import ( + "bufio" + "bytes" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/safedep/dry/log" +) + +// tlsHandshakeRecord is the first byte of a TLS handshake record. No HTTP +// method starts with it, so it cleanly separates a redirected client (which +// speaks TLS immediately) from an explicit proxy client (which sends CONNECT). +const tlsHandshakeRecord = 0x16 + +// transparentPort is the port used for the synthesised CONNECT. A connect +// rewrite overwrites the destination before the connection is made, so the +// original port is not recoverable here. Only 443 is redirected today. +const transparentPort = 443 + +// maxConnectResponseBytes bounds how much of the proxy's CONNECT response is +// buffered while looking for its terminator, so a malformed response cannot +// grow the buffer without limit. +const maxConnectResponseBytes = 8 << 10 + +// defaultSniffTimeout bounds how long a freshly accepted connection may take +// to reveal whether it is HTTP or TLS. +const defaultSniffTimeout = 30 * time.Second + +var ( + errSniffOnly = errors.New("proxy: client hello sniff completed") + errNoSNI = errors.New("proxy: client hello carries no server name") +) + +// transparentListener accepts both explicit proxy clients and redirected +// clients on one listener. A redirected client believes it reached the real +// registry, so it never sends CONNECT. Its destination is recovered from the +// ClientHello's SNI and handed to the proxy handler as a CONNECT request, +// which keeps every downstream decision on the existing code path. +type transparentListener struct { + net.Listener + + handler http.Handler + sniffTimeout time.Duration +} + +func newTransparentListener(inner net.Listener, handler http.Handler) net.Listener { + return &transparentListener{ + Listener: inner, + handler: handler, + sniffTimeout: defaultSniffTimeout, + } +} + +// Accept returns explicit clients to the http.Server as usual. Redirected +// clients are served on their own goroutine and never returned, because +// http.Server issues a background read while a handler runs and would consume +// the first byte of the replayed ClientHello. +func (l *transparentListener) Accept() (net.Conn, error) { + for { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + + explicit, redirected, err := l.classify(conn) + if err != nil { + log.Debugf("Transparent listener dropped connection from %s: %v", conn.RemoteAddr(), err) + closeConn(conn) + + continue + } + + if explicit != nil { + return explicit, nil + } + + go l.serveRedirected(redirected) + } +} + +// redirectedConn is an accepted connection whose destination was recovered +// from its ClientHello. +type redirectedConn struct { + conn *transparentConn + host string +} + +func (l *transparentListener) classify(conn net.Conn) (net.Conn, *redirectedConn, error) { + if err := conn.SetReadDeadline(time.Now().Add(l.sniffTimeout)); err != nil { + return nil, nil, fmt.Errorf("failed to set sniff deadline: %w", err) + } + + buffered := bufio.NewReader(conn) + + first, err := buffered.Peek(1) + if err != nil { + return nil, nil, fmt.Errorf("failed to peek first byte: %w", err) + } + + if first[0] != tlsHandshakeRecord { + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return nil, nil, fmt.Errorf("failed to clear sniff deadline: %w", err) + } + + return &bufferedConn{Conn: conn, reader: buffered}, nil, nil + } + + host, hello, err := sniffServerName(conn, buffered) + if err != nil { + return nil, nil, err + } + + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return nil, nil, fmt.Errorf("failed to clear sniff deadline: %w", err) + } + + replay := &transparentConn{ + Conn: conn, + body: io.MultiReader(bytes.NewReader(hello), buffered), + } + + return nil, &redirectedConn{conn: replay, host: host}, nil +} + +// serveRedirected drives the proxy handler for a redirected connection by +// synthesising the CONNECT request the client never sent. +func (l *transparentListener) serveRedirected(redirected *redirectedConn) { + defer func() { + if r := recover(); r != nil { + log.Errorf("Panic serving redirected connection: %v", r) + closeConn(redirected.conn) + } + }() + + hostPort := net.JoinHostPort(redirected.host, strconv.Itoa(transparentPort)) + + req := &http.Request{ + Method: http.MethodConnect, + Host: hostPort, + URL: &url.URL{Host: hostPort}, + Header: make(http.Header), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + RemoteAddr: redirected.conn.RemoteAddr().String(), + Body: http.NoBody, + } + + writer := &hijackWriter{conn: redirected.conn, header: make(http.Header)} + + log.Debugf("Serving redirected connection from %s as CONNECT %s", + redirected.conn.RemoteAddr(), hostPort) + + l.handler.ServeHTTP(writer, req) + + // The proxy hijacks on the paths that matter and owns the connection from + // then on. Anything that returns without hijacking has nothing more to say. + if !writer.hijacked { + closeConn(redirected.conn) + } +} + +func closeConn(conn net.Conn) { + if err := conn.Close(); err != nil { + log.Debugf("Failed to close connection: %v", err) + } +} + +// hijackWriter is the minimal http.ResponseWriter the proxy handler needs. The +// CONNECT path immediately hijacks and writes to the connection directly. +type hijackWriter struct { + conn net.Conn + header http.Header + hijacked bool +} + +func (w *hijackWriter) Header() http.Header { return w.header } + +func (w *hijackWriter) Write(p []byte) (int, error) { return w.conn.Write(p) } + +func (w *hijackWriter) WriteHeader(int) {} + +func (w *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + w.hijacked = true + return w.conn, bufio.NewReadWriter(bufio.NewReader(w.conn), bufio.NewWriter(w.conn)), nil +} + +// bufferedConn serves reads from a bufio.Reader so bytes consumed while +// sniffing are not lost. Explicit proxy clients take this path unchanged. +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +// sniffConn feeds the TLS handshake from src while recording every byte it +// consumes, so the ClientHello can be replayed afterwards. Writes are refused +// to abort the handshake once the server name has been read. +type sniffConn struct { + net.Conn + src io.Reader + consumed bytes.Buffer +} + +func (c *sniffConn) Read(p []byte) (int, error) { + n, err := c.src.Read(p) + if n > 0 { + c.consumed.Write(p[:n]) + } + + return n, err +} + +func (c *sniffConn) Write(_ []byte) (int, error) { + return 0, errSniffOnly +} + +// sniffServerName reads just far enough into the TLS handshake to learn the +// requested server name. GetConfigForClient fires after the ClientHello is +// parsed, so returning an error there stops the handshake before anything is +// sent back to the client. +func sniffServerName(conn net.Conn, src io.Reader) (string, []byte, error) { + sniffer := &sniffConn{Conn: conn, src: src} + + var serverName string + config := &tls.Config{ + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + serverName = hello.ServerName + return nil, errSniffOnly + }, + } + + // The handshake is expected to fail, that is how it is stopped. Only treat + // it as an error when no server name was recovered. + handshakeErr := tls.Server(sniffer, config).Handshake() + if serverName == "" { + return "", nil, fmt.Errorf("%w: %v", errNoSNI, handshakeErr) + } + + return serverName, sniffer.consumed.Bytes(), nil +} + +// transparentConn replays the ClientHello consumed during sniffing and hides +// the CONNECT response from a client that never sent a CONNECT. +type transparentConn struct { + net.Conn + + body io.Reader + + responseDropped bool + pending bytes.Buffer +} + +func (c *transparentConn) Read(p []byte) (int, error) { + return c.body.Read(p) +} + +// Write drops the proxy's response to the synthetic CONNECT. The client is +// mid TLS handshake and expects a ServerHello, so those bytes would be read +// as a malformed TLS record and kill the connection. Everything after the +// response terminator is the real handshake and passes straight through. +func (c *transparentConn) Write(p []byte) (int, error) { + if c.responseDropped { + return c.Conn.Write(p) + } + + c.pending.Write(p) + + terminator := bytes.Index(c.pending.Bytes(), []byte("\r\n\r\n")) + if terminator < 0 { + if c.pending.Len() > maxConnectResponseBytes { + return 0, errors.New("proxy: CONNECT response exceeded buffer before terminator") + } + + return len(p), nil + } + + c.responseDropped = true + + remainder := bytes.Clone(c.pending.Bytes()[terminator+4:]) + c.pending.Reset() + + if len(remainder) > 0 { + if _, err := c.Conn.Write(remainder); err != nil { + return 0, err + } + } + + return len(p), nil +} diff --git a/proxy/transparent_test.go b/proxy/transparent_test.go new file mode 100644 index 0000000..3991913 --- /dev/null +++ b/proxy/transparent_test.go @@ -0,0 +1,182 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildTransparentProxy wires a MITM proxy that accepts redirected connections +// and routes every upstream hostname to the given test server. +func buildTransparentProxy(t *testing.T, host, upstreamAddr string) *proxyServer { + t.Helper() + + cfg := DefaultProxyConfig() + cfg.CertManager = newReproCertManager(t) + cfg.EnableTransparent = true + cfg.Interceptors = []Interceptor{&reproInterceptor{host: host}} + cfg.UpstreamDialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, upstreamAddr) + } + cfg.UpstreamTLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + server, err := NewProxyServer(cfg) + require.NoError(t, err) + + ps := server.(*proxyServer) + ps.proxy.Tr.TLSClientConfig.InsecureSkipVerify = true + + require.NoError(t, ps.Start()) + t.Cleanup(func() { _ = ps.Stop(t.Context()) }) + + return ps +} + +func newEchoUpstream(t *testing.T, paths chan<- string) *httptest.Server { + t.Helper() + + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths <- r.URL.Path + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("ok")); err != nil { + t.Errorf("upstream write failed: %v", err) + } + })) + t.Cleanup(upstream.Close) + + return upstream +} + +// TestTransparentConnectionInterceptedWithoutConnect is the core case for an +// eBPF or nftables redirect: the client never sends CONNECT, it opens TLS +// straight away believing it reached the registry. The proxy must recover the +// destination from SNI and MITM it exactly as it would an explicit client. +func TestTransparentConnectionInterceptedWithoutConnect(t *testing.T) { + const host = "registry.npmjs.org" + + paths := make(chan string, 1) + upstream := newEchoUpstream(t, paths) + ps := buildTransparentProxy(t, host, strings.TrimPrefix(upstream.URL, "https://")) + + raw, err := net.Dial("tcp", ps.Address()) + require.NoError(t, err) + t.Cleanup(func() { _ = raw.Close() }) + + // No CONNECT. This is what the kernel hands the proxy after a redirect. + conn := tls.Client(raw, &tls.Config{ServerName: host, InsecureSkipVerify: true}) + require.NoError(t, conn.Handshake()) + + certs := conn.ConnectionState().PeerCertificates + require.NotEmpty(t, certs) + assert.Contains(t, certs[0].DNSNames, host, "expected a certificate minted for the SNI host") + + req, err := http.NewRequest(http.MethodGet, "https://"+host+"/express", nil) + require.NoError(t, err) + require.NoError(t, req.Write(conn)) + + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "/express", <-paths) +} + +// TestExplicitClientUnaffectedByTransparentListener guards backward +// compatibility: turning the transparent listener on must not change how an +// ordinary proxy-configured client is served. +func TestExplicitClientUnaffectedByTransparentListener(t *testing.T) { + const host = "registry.npmjs.org" + + paths := make(chan string, 1) + upstream := newEchoUpstream(t, paths) + ps := buildTransparentProxy(t, host, strings.TrimPrefix(upstream.URL, "https://")) + + proxyURL, err := url.Parse("http://" + ps.Address()) + require.NoError(t, err) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + resp, err := client.Get("https://" + host + "/lodash") + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "/lodash", <-paths) +} + +// TestTransparentConnWriteDropsConnectResponse pins the write side in +// isolation: the synthetic CONNECT's response must never reach the client, +// and the TLS bytes that follow it must pass through untouched. +func TestTransparentConnWriteDropsConnectResponse(t *testing.T) { + tests := []struct { + name string + writes []string + want string + }{ + { + name: "response and payload in one write", + writes: []string{"HTTP/1.1 200 Connection Established\r\n\r\n\x16\x03\x03payload"}, + want: "\x16\x03\x03payload", + }, + { + name: "response split across writes", + writes: []string{"HTTP/1.1 200 Connection", " Established\r\n", "\r\n", "\x16after"}, + want: "\x16after", + }, + { + name: "later writes pass through unchanged", + writes: []string{"HTTP/1.1 200 OK\r\n\r\n", "first", "second"}, + want: "firstsecond", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + + // net.Pipe is unbuffered, so the reader must keep draining while + // the writes happen and stop only once the writer closes. + got := make(chan string, 1) + go func() { + var received strings.Builder + buf := make([]byte, 512) + for { + n, err := client.Read(buf) + if n > 0 { + received.Write(buf[:n]) + } + if err != nil { + break + } + } + got <- received.String() + }() + + tc := &transparentConn{Conn: server} + for _, w := range tt.writes { + n, err := tc.Write([]byte(w)) + require.NoError(t, err) + assert.Equal(t, len(w), n, "Write must report the full length to the caller") + } + + require.NoError(t, server.Close()) + assert.Equal(t, tt.want, <-got) + }) + } +}