mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(proxy): accept transparent redirected connections
The proxy only accepted explicit clients, which announce their destination with a CONNECT request. A client whose connection is redirected at the kernel level speaks TLS immediately instead, so http.Server tries to parse a TLS record as an HTTP request line and drops the connection. Demultiplex on the first byte of an accepted connection. A TLS handshake record (0x16) cannot begin an HTTP method, so it separates the two cleanly. Redirected connections have their destination recovered from the ClientHello SNI and are served by synthesising the CONNECT the client never sent, which keeps the MITM decision, cert generation and interceptor chain on the existing code path. Redirected connections deliberately bypass http.Server. It issues a background read while a handler runs, which consumes the first byte of the replayed ClientHello and corrupts the handshake. The CONNECT response is also suppressed, since a client mid handshake expects a ServerHello and would read those bytes as a malformed TLS record. Off by default. Enabled with `pmg proxy start --transparent` or proxy.server.transparent, and only useful alongside a redirect mechanism such as an eBPF connect rewrite.
This commit is contained in:
+11
-4
@@ -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().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().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().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: <cache-dir>/proxy.log)")
|
cmd.Flags().StringVar(&logFileFlag, "log-file", "", "File for the daemon's output (default: <cache-dir>/proxy.log)")
|
||||||
cmd.Flags().BoolVar(&foregroundInternalFlag, "foreground-internal", false, "Internal: run the foreground server (used by --daemon)")
|
cmd.Flags().BoolVar(&foregroundInternalFlag, "foreground-internal", false, "Internal: run the foreground server (used by --daemon)")
|
||||||
if err := cmd.Flags().MarkHidden("foreground-internal"); err != nil {
|
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())
|
statePath := proxyserver.ResolveStatePath(stateFlag, cfg.CacheDir())
|
||||||
host := cfg.Config.Proxy.Server.ListenHost
|
host := cfg.Config.Proxy.Server.ListenHost
|
||||||
port := cfg.Config.Proxy.Server.ListenPort
|
port := cfg.Config.Proxy.Server.ListenPort
|
||||||
|
transparent := cfg.Config.Proxy.Server.Transparent
|
||||||
|
|
||||||
if daemonFlag && !foregroundInternalFlag {
|
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)
|
ui.ErrorExit(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -61,7 +64,7 @@ func runStart(cmd *cobra.Command, _ []string) error {
|
|||||||
return nil
|
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()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve executable: %w", err)
|
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)
|
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{
|
daemonCfg := proxyserver.ProxyDaemonConfig{
|
||||||
LogPath: logPath,
|
LogPath: logPath,
|
||||||
@@ -92,12 +95,16 @@ func startDaemon(cmd *cobra.Command, cfg *config.RuntimeConfig, statePath, host
|
|||||||
return werr
|
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)...)
|
args := append([]string{}, config.ChangedConfigFlagArgs(cmd)...)
|
||||||
return append(args,
|
return append(args,
|
||||||
"proxy", "start", "--foreground-internal",
|
"proxy", "start", "--foreground-internal",
|
||||||
"--state", statePath,
|
"--state", statePath,
|
||||||
"--host", host,
|
"--host", host,
|
||||||
"--port", strconv.Itoa(port),
|
"--port", strconv.Itoa(port),
|
||||||
|
"--transparent="+strconv.FormatBool(transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -17,7 +17,7 @@ func TestDaemonArgsPrependsChangedConfigFlags(t *testing.T) {
|
|||||||
start := &cobra.Command{
|
start := &cobra.Command{
|
||||||
Use: "start",
|
Use: "start",
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
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"}
|
proxyCmd := &cobra.Command{Use: "proxy"}
|
||||||
@@ -37,5 +37,28 @@ func TestDaemonArgsPrependsChangedConfigFlags(t *testing.T) {
|
|||||||
"--state", "/tmp/proxy-state.json",
|
"--state", "/tmp/proxy-state.json",
|
||||||
"--host", "127.0.0.1",
|
"--host", "127.0.0.1",
|
||||||
"--port", "9000",
|
"--port", "9000",
|
||||||
|
"--transparent=false",
|
||||||
}, got)
|
}, 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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ type ProxyServerConfig struct {
|
|||||||
// ListenPort is the port the persistent proxy binds to. 0 (default) means a
|
// ListenPort is the port the persistent proxy binds to. 0 (default) means a
|
||||||
// random free port. The --port flag overrides this.
|
// random free port. The --port flag overrides this.
|
||||||
ListenPort int `mapstructure:"listen_port"`
|
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.
|
// SandboxConfig configures the sandbox system for isolating package manager processes.
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string,
|
|||||||
|
|
||||||
proxyConfig := pmgproxy.DefaultProxyConfig()
|
proxyConfig := pmgproxy.DefaultProxyConfig()
|
||||||
proxyConfig.ListenAddr = listenAddr(host, port)
|
proxyConfig.ListenAddr = listenAddr(host, port)
|
||||||
|
proxyConfig.EnableTransparent = cfg.Config.Proxy.Server.Transparent
|
||||||
proxyConfig.CertManager = certMgr
|
proxyConfig.CertManager = certMgr
|
||||||
proxyConfig.Interceptors = interceptorList
|
proxyConfig.Interceptors = interceptorList
|
||||||
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ type ProxyConfig struct {
|
|||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
ConnectTimeout 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
|
// ServerReadWriteTimeout is the timeout applied to the http.Server's
|
||||||
// ReadTimeout and WriteTimeout. These deadlines are set on the raw TCP
|
// ReadTimeout and WriteTimeout. These deadlines are set on the raw TCP
|
||||||
// connection and persist after Hijack(), which means they become the
|
// 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)
|
return fmt.Errorf("failed to start listener: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ps.config.EnableTransparent {
|
||||||
|
listener = newTransparentListener(listener, ps.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
ps.listener = listener
|
ps.listener = listener
|
||||||
|
|
||||||
serverTimeout := ps.config.ServerReadWriteTimeout
|
serverTimeout := ps.config.ServerReadWriteTimeout
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user