-
Notifications
You must be signed in to change notification settings - Fork 40
feat: Add transparent listener + enforce-redirect for proxy-sidecar egress #485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
huang195
merged 7 commits into
rossoctl:main
from
huang195:feat/transparent-proxy-egress-redirect
Jun 6, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
bb15d20
feat: Add transparent listener + enforce-redirect for proxy-sidecar e…
huang195 f884dc4
fix(proxy-init): split enforce-redirect into nat REDIRECT + mangle DROP
huang195 cae8cfe
docs,fix: Address CodeRabbit review on transparent-proxy PR
huang195 6646b57
fix,docs: Loopback self-redirect guard + document IP-level gating
huang195 66f82e9
feat(transparent): Recover destination hostname (SNI + HTTP Host) for…
huang195 bbffe8f
refactor(proxy-init): remove obsolete enforce-drop mode
huang195 178c66e
fix(transparentproxy): also drop self-dial to the listener's own address
huang195 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| package forwardproxy | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "bytes" | ||
| cryptotls "crypto/tls" | ||
| "errors" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "time" | ||
| ) | ||
|
|
||
| // Captured (iptables-REDIRECTed) connections carry no CONNECT line, so the | ||
| // destination hostname must be recovered from the connection's own first bytes: | ||
| // the TLS ClientHello SNI for HTTPS, or the HTTP Host header for plaintext HTTP. | ||
| // This gives policy parity with the explicit-proxy path (which reads r.Host). | ||
| // The recovered name is used ONLY as the policy key (pctx.Host); the dial target | ||
| // stays the SO_ORIGINAL_DST IP. See HandleTransparentConn. | ||
|
|
||
| const ( | ||
| // sniffBufSize bounds how much of the leading bytes we buffer to find the | ||
| // SNI / Host header. Real ClientHellos and request header blocks fit well | ||
| // within this; anything larger falls back to the IP. | ||
| sniffBufSize = 8192 | ||
| // sniffTimeout bounds the peek so a client that connects but sends nothing | ||
| // (or a server-first protocol) can't pin a goroutine. Only relevant on the | ||
| // sniffed ports, where the client speaks first, so it is rarely hit. | ||
| sniffTimeout = 5 * time.Second | ||
| ) | ||
|
|
||
| // errSniffDone aborts the throwaway TLS handshake once we have the SNI. | ||
| var errSniffDone = errors.New("forwardproxy: sni sniff complete") | ||
|
|
||
| // shouldSniff reports whether dst's port is one where the client speaks first | ||
| // with an HTTP/TLS preamble we can parse. Gating on these ports avoids adding | ||
| // peek latency to non-HTTP, often server-first protocols (SSH:22, SMTP:25, ...) | ||
| // that we would only ever blind-tunnel anyway. | ||
| func shouldSniff(dst string) bool { | ||
| _, port, err := net.SplitHostPort(dst) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| switch port { | ||
| case "80", "443", "8080", "8443": | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| // sniffHost peeks the leading bytes of conn to recover the destination hostname | ||
| // (TLS SNI or HTTP Host header) without consuming them: it returns the hostname | ||
| // (without port; "" if none could be recovered) and a net.Conn that replays the | ||
| // peeked bytes so the downstream tunnel still forwards the handshake/request | ||
| // verbatim. A read deadline bounds the peek and is cleared before returning. | ||
| func sniffHost(conn net.Conn) (string, net.Conn) { | ||
| br := bufio.NewReaderSize(conn, sniffBufSize) | ||
| wrapped := &peekedConn{Conn: conn, r: br} | ||
|
|
||
| _ = conn.SetReadDeadline(time.Now().Add(sniffTimeout)) | ||
| defer func() { _ = conn.SetReadDeadline(time.Time{}) }() | ||
|
|
||
| first, err := br.Peek(1) | ||
| if err != nil || len(first) == 0 { | ||
| return "", wrapped | ||
| } | ||
| switch { | ||
| case first[0] == 0x16: // TLS handshake record | ||
| return stripPort(sniffTLSSNI(br)), wrapped | ||
| case first[0] >= 'A' && first[0] <= 'Z': // ASCII HTTP method | ||
| return stripPort(sniffHTTPHost(br)), wrapped | ||
| default: | ||
| return "", wrapped | ||
| } | ||
| } | ||
|
|
||
| // sniffTLSSNI peeks the first TLS record (the ClientHello) and extracts the SNI. | ||
| func sniffTLSSNI(br *bufio.Reader) string { | ||
| hdr, err := br.Peek(5) | ||
| if err != nil || len(hdr) < 5 { | ||
| return "" | ||
| } | ||
| end := 5 + (int(hdr[3])<<8 | int(hdr[4])) | ||
| if end > br.Size() { | ||
| end = br.Size() | ||
| } | ||
| full, _ := br.Peek(end) // best effort: parse whatever is buffered | ||
| return extractSNI(full) | ||
| } | ||
|
|
||
| // extractSNI parses the SNI out of a buffered ClientHello by driving a throwaway | ||
| // server-side handshake over the bytes and capturing ServerName in the config | ||
| // callback, then aborting. Leans on crypto/tls's hardened parser rather than a | ||
| // hand-rolled one. Returns "" if the bytes are not a parseable ClientHello. | ||
| func extractSNI(clientHello []byte) string { | ||
| var sni string | ||
| _ = cryptotls.Server(readOnlyConn{r: bytes.NewReader(clientHello)}, &cryptotls.Config{ | ||
| GetConfigForClient: func(chi *cryptotls.ClientHelloInfo) (*cryptotls.Config, error) { | ||
| sni = chi.ServerName | ||
| return nil, errSniffDone | ||
| }, | ||
| }).Handshake() | ||
| return sni | ||
| } | ||
|
|
||
| // sniffHTTPHost peeks the request header block and returns the Host header. | ||
| func sniffHTTPHost(br *bufio.Reader) string { | ||
| end := br.Buffered() | ||
| if end < 1 { | ||
| end = 1 | ||
| } | ||
| for { | ||
| buf, err := br.Peek(end) | ||
| if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 { | ||
| return parseHTTPHost(buf[:i+4]) | ||
| } | ||
| if err != nil || end >= br.Size() { | ||
| return parseHTTPHost(buf) // best effort on what we have | ||
| } | ||
| end++ | ||
| } | ||
| } | ||
|
|
||
| func parseHTTPHost(headerBytes []byte) string { | ||
| req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(headerBytes))) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return req.Host | ||
| } | ||
|
|
||
| // stripPort drops a trailing :port if present (HTTP Host headers may carry one; | ||
| // SNI never does). The real port comes from the SO_ORIGINAL_DST destination. | ||
| func stripPort(host string) string { | ||
| if host == "" { | ||
| return "" | ||
| } | ||
| if h, _, err := net.SplitHostPort(host); err == nil { | ||
| return h | ||
| } | ||
| return host | ||
| } | ||
|
|
||
| // peekedConn is a net.Conn whose Read replays bytes buffered by a bufio.Reader | ||
| // during sniffing, then continues from the underlying conn. Writes, Close, | ||
| // deadlines, and addresses delegate to the embedded conn. | ||
| type peekedConn struct { | ||
| net.Conn | ||
| r *bufio.Reader | ||
| } | ||
|
|
||
| func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } | ||
|
|
||
| // readOnlyConn adapts a byte buffer to net.Conn for crypto/tls's server-side | ||
| // parser. Reads come from the buffer; writes are discarded (the throwaway | ||
| // handshake never needs to send), and the parser aborts via errSniffDone before | ||
| // any write would matter. | ||
| type readOnlyConn struct{ r io.Reader } | ||
|
|
||
| func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) } | ||
| func (c readOnlyConn) Write(p []byte) (int, error) { return len(p), nil } | ||
| func (c readOnlyConn) Close() error { return nil } | ||
| func (c readOnlyConn) LocalAddr() net.Addr { return nil } | ||
| func (c readOnlyConn) RemoteAddr() net.Addr { return nil } | ||
| func (c readOnlyConn) SetDeadline(time.Time) error { return nil } | ||
| func (c readOnlyConn) SetReadDeadline(time.Time) error { return nil } | ||
| func (c readOnlyConn) SetWriteDeadline(time.Time) error { return nil } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.