|
| 1 | +package forwardproxy |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "bytes" |
| 6 | + cryptotls "crypto/tls" |
| 7 | + "errors" |
| 8 | + "io" |
| 9 | + "net" |
| 10 | + "net/http" |
| 11 | + "time" |
| 12 | +) |
| 13 | + |
| 14 | +// Captured (iptables-REDIRECTed) connections carry no CONNECT line, so the |
| 15 | +// destination hostname must be recovered from the connection's own first bytes: |
| 16 | +// the TLS ClientHello SNI for HTTPS, or the HTTP Host header for plaintext HTTP. |
| 17 | +// This gives policy parity with the explicit-proxy path (which reads r.Host). |
| 18 | +// The recovered name is used ONLY as the policy key (pctx.Host); the dial target |
| 19 | +// stays the SO_ORIGINAL_DST IP. See HandleTransparentConn. |
| 20 | + |
| 21 | +const ( |
| 22 | + // sniffBufSize bounds how much of the leading bytes we buffer to find the |
| 23 | + // SNI / Host header. Real ClientHellos and request header blocks fit well |
| 24 | + // within this; anything larger falls back to the IP. |
| 25 | + sniffBufSize = 8192 |
| 26 | + // sniffTimeout bounds the peek so a client that connects but sends nothing |
| 27 | + // (or a server-first protocol) can't pin a goroutine. Only relevant on the |
| 28 | + // sniffed ports, where the client speaks first, so it is rarely hit. |
| 29 | + sniffTimeout = 5 * time.Second |
| 30 | +) |
| 31 | + |
| 32 | +// errSniffDone aborts the throwaway TLS handshake once we have the SNI. |
| 33 | +var errSniffDone = errors.New("forwardproxy: sni sniff complete") |
| 34 | + |
| 35 | +// shouldSniff reports whether dst's port is one where the client speaks first |
| 36 | +// with an HTTP/TLS preamble we can parse. Gating on these ports avoids adding |
| 37 | +// peek latency to non-HTTP, often server-first protocols (SSH:22, SMTP:25, ...) |
| 38 | +// that we would only ever blind-tunnel anyway. |
| 39 | +func shouldSniff(dst string) bool { |
| 40 | + _, port, err := net.SplitHostPort(dst) |
| 41 | + if err != nil { |
| 42 | + return false |
| 43 | + } |
| 44 | + switch port { |
| 45 | + case "80", "443", "8080", "8443": |
| 46 | + return true |
| 47 | + default: |
| 48 | + return false |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +// sniffHost peeks the leading bytes of conn to recover the destination hostname |
| 53 | +// (TLS SNI or HTTP Host header) without consuming them: it returns the hostname |
| 54 | +// (without port; "" if none could be recovered) and a net.Conn that replays the |
| 55 | +// peeked bytes so the downstream tunnel still forwards the handshake/request |
| 56 | +// verbatim. A read deadline bounds the peek and is cleared before returning. |
| 57 | +func sniffHost(conn net.Conn) (string, net.Conn) { |
| 58 | + br := bufio.NewReaderSize(conn, sniffBufSize) |
| 59 | + wrapped := &peekedConn{Conn: conn, r: br} |
| 60 | + |
| 61 | + _ = conn.SetReadDeadline(time.Now().Add(sniffTimeout)) |
| 62 | + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() |
| 63 | + |
| 64 | + first, err := br.Peek(1) |
| 65 | + if err != nil || len(first) == 0 { |
| 66 | + return "", wrapped |
| 67 | + } |
| 68 | + switch { |
| 69 | + case first[0] == 0x16: // TLS handshake record |
| 70 | + return stripPort(sniffTLSSNI(br)), wrapped |
| 71 | + case first[0] >= 'A' && first[0] <= 'Z': // ASCII HTTP method |
| 72 | + return stripPort(sniffHTTPHost(br)), wrapped |
| 73 | + default: |
| 74 | + return "", wrapped |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +// sniffTLSSNI peeks the first TLS record (the ClientHello) and extracts the SNI. |
| 79 | +func sniffTLSSNI(br *bufio.Reader) string { |
| 80 | + hdr, err := br.Peek(5) |
| 81 | + if err != nil || len(hdr) < 5 { |
| 82 | + return "" |
| 83 | + } |
| 84 | + end := 5 + (int(hdr[3])<<8 | int(hdr[4])) |
| 85 | + if end > br.Size() { |
| 86 | + end = br.Size() |
| 87 | + } |
| 88 | + full, _ := br.Peek(end) // best effort: parse whatever is buffered |
| 89 | + return extractSNI(full) |
| 90 | +} |
| 91 | + |
| 92 | +// extractSNI parses the SNI out of a buffered ClientHello by driving a throwaway |
| 93 | +// server-side handshake over the bytes and capturing ServerName in the config |
| 94 | +// callback, then aborting. Leans on crypto/tls's hardened parser rather than a |
| 95 | +// hand-rolled one. Returns "" if the bytes are not a parseable ClientHello. |
| 96 | +func extractSNI(clientHello []byte) string { |
| 97 | + var sni string |
| 98 | + _ = cryptotls.Server(readOnlyConn{r: bytes.NewReader(clientHello)}, &cryptotls.Config{ |
| 99 | + GetConfigForClient: func(chi *cryptotls.ClientHelloInfo) (*cryptotls.Config, error) { |
| 100 | + sni = chi.ServerName |
| 101 | + return nil, errSniffDone |
| 102 | + }, |
| 103 | + }).Handshake() |
| 104 | + return sni |
| 105 | +} |
| 106 | + |
| 107 | +// sniffHTTPHost peeks the request header block and returns the Host header. |
| 108 | +func sniffHTTPHost(br *bufio.Reader) string { |
| 109 | + end := br.Buffered() |
| 110 | + if end < 1 { |
| 111 | + end = 1 |
| 112 | + } |
| 113 | + for { |
| 114 | + buf, err := br.Peek(end) |
| 115 | + if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 { |
| 116 | + return parseHTTPHost(buf[:i+4]) |
| 117 | + } |
| 118 | + if err != nil || end >= br.Size() { |
| 119 | + return parseHTTPHost(buf) // best effort on what we have |
| 120 | + } |
| 121 | + end++ |
| 122 | + } |
| 123 | +} |
| 124 | + |
| 125 | +func parseHTTPHost(headerBytes []byte) string { |
| 126 | + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(headerBytes))) |
| 127 | + if err != nil { |
| 128 | + return "" |
| 129 | + } |
| 130 | + return req.Host |
| 131 | +} |
| 132 | + |
| 133 | +// stripPort drops a trailing :port if present (HTTP Host headers may carry one; |
| 134 | +// SNI never does). The real port comes from the SO_ORIGINAL_DST destination. |
| 135 | +func stripPort(host string) string { |
| 136 | + if host == "" { |
| 137 | + return "" |
| 138 | + } |
| 139 | + if h, _, err := net.SplitHostPort(host); err == nil { |
| 140 | + return h |
| 141 | + } |
| 142 | + return host |
| 143 | +} |
| 144 | + |
| 145 | +// peekedConn is a net.Conn whose Read replays bytes buffered by a bufio.Reader |
| 146 | +// during sniffing, then continues from the underlying conn. Writes, Close, |
| 147 | +// deadlines, and addresses delegate to the embedded conn. |
| 148 | +type peekedConn struct { |
| 149 | + net.Conn |
| 150 | + r *bufio.Reader |
| 151 | +} |
| 152 | + |
| 153 | +func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } |
| 154 | + |
| 155 | +// readOnlyConn adapts a byte buffer to net.Conn for crypto/tls's server-side |
| 156 | +// parser. Reads come from the buffer; writes are discarded (the throwaway |
| 157 | +// handshake never needs to send), and the parser aborts via errSniffDone before |
| 158 | +// any write would matter. |
| 159 | +type readOnlyConn struct{ r io.Reader } |
| 160 | + |
| 161 | +func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) } |
| 162 | +func (c readOnlyConn) Write(p []byte) (int, error) { return len(p), nil } |
| 163 | +func (c readOnlyConn) Close() error { return nil } |
| 164 | +func (c readOnlyConn) LocalAddr() net.Addr { return nil } |
| 165 | +func (c readOnlyConn) RemoteAddr() net.Addr { return nil } |
| 166 | +func (c readOnlyConn) SetDeadline(time.Time) error { return nil } |
| 167 | +func (c readOnlyConn) SetReadDeadline(time.Time) error { return nil } |
| 168 | +func (c readOnlyConn) SetWriteDeadline(time.Time) error { return nil } |
0 commit comments