Files
pmg/proxy/transparent.go
T

303 lines
8.5 KiB
Go
Raw Normal View History

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
}