From f978ed44b892b326014f06aff3552b358a4664f5 Mon Sep 17 00:00:00 2001 From: thuanle Date: Tue, 28 Apr 2026 10:56:57 +0700 Subject: [PATCH] feat: support http/https proxy for IMAP via HTTP CONNECT tunnel Extend IMAP_PROXY_URL to accept http:// and https:// schemes in addition to the existing socks5:// support. HTTP CONNECT tunneling is used for both new schemes, with TLS to the proxy for https://. Proxy auth via user:pass@ in the URL is supported for all schemes. Refactors dialTLSViaSOCKS5 into a generic dialTLSViaProxy dispatcher. Fixes #9 Co-Authored-By: Claude Opus 4.7 --- internal/mail/imap.go | 129 ++++++++++++++++++---- internal/mail/imap_test.go | 219 ++++++++++++++++++++++++++++++++++++- 2 files changed, 319 insertions(+), 29 deletions(-) diff --git a/internal/mail/imap.go b/internal/mail/imap.go index ab9bd12..2e84fa7 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -1,13 +1,16 @@ package mail import ( + "bufio" "context" "crypto/tls" + "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "net" + "net/http" "net/url" "strings" "time" @@ -59,6 +62,10 @@ func (d timeoutContextDialer) DialContext(ctx context.Context, network, address var socks5DialerFactory = proxy.SOCKS5 +var proxyTLSConfigForTest = func(host string) *tls.Config { + return &tls.Config{ServerName: host} +} + // NewIMAPWatcher creates a new IMAP watcher. func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { var externalRules []rules.Rule @@ -73,7 +80,7 @@ func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { cfg: cfg, db: db, dialIMAP: imapclient.DialTLS, - dialIMAPViaProxy: dialTLSViaSOCKS5, + dialIMAPViaProxy: dialTLSViaProxy, rules: rules.NewPipeline(externalRules), } } @@ -193,34 +200,20 @@ func (w *IMAPWatcher) connectAndWatch(ctx context.Context) error { } } -func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) { +func dialTLSViaProxy(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) { u, err := url.Parse(proxyURL) if err != nil { return nil, fmt.Errorf("parse proxy url: %w", err) } - if u.Scheme != "socks5" { - return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme) - } - - var auth *proxy.Auth - if u.User != nil { - pw, _ := u.User.Password() - auth = &proxy.Auth{User: u.User.Username(), Password: pw} - } - - dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutContextDialer{timeout: imapDialTimeout}) - if err != nil { - return nil, fmt.Errorf("create socks5 dialer: %w", err) - } - - dialCtx, dialCancel := context.WithTimeout(context.Background(), imapDialTimeout) - defer dialCancel() var conn net.Conn - if cd, ok := dialer.(proxy.ContextDialer); ok { - conn, err = cd.DialContext(dialCtx, "tcp", addr) - } else { - conn, err = dialer.Dial("tcp", addr) + switch u.Scheme { + case "socks5": + conn, err = dialViaSOCKS5(u, addr) + case "http", "https": + conn, err = dialViaCONNECT(u, addr) + default: + return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme) } if err != nil { return nil, fmt.Errorf("proxy dial: %w", err) @@ -243,6 +236,96 @@ func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imap return imapclient.New(tlsConn, options), nil } +func dialViaSOCKS5(u *url.URL, targetAddr string) (net.Conn, error) { + var auth *proxy.Auth + if u.User != nil { + pw, _ := u.User.Password() + auth = &proxy.Auth{User: u.User.Username(), Password: pw} + } + + dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutContextDialer{timeout: imapDialTimeout}) + if err != nil { + return nil, fmt.Errorf("create socks5 dialer: %w", err) + } + + dialCtx, dialCancel := context.WithTimeout(context.Background(), imapDialTimeout) + defer dialCancel() + + if cd, ok := dialer.(proxy.ContextDialer); ok { + return cd.DialContext(dialCtx, "tcp", targetAddr) + } + return dialer.Dial("tcp", targetAddr) +} + +// proxyConn wraps a net.Conn, draining buffered data from a bufio.Reader +// before delegating reads to the underlying connection. +type proxyConn struct { + net.Conn + reader io.Reader +} + +func (c *proxyConn) Read(b []byte) (int, error) { + return c.reader.Read(b) +} + +// dialToProxy is the function used to establish a TCP connection to the proxy. +var dialToProxy = func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{Timeout: imapDialTimeout}).DialContext(ctx, network, addr) +} + +func dialViaCONNECT(u *url.URL, targetAddr string) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), imapDialTimeout) + defer cancel() + + conn, err := dialToProxy(ctx, "tcp", u.Host) + if err != nil { + return nil, fmt.Errorf("dial proxy %s: %w", u.Host, err) + } + + if u.Scheme == "https" { + tlsConn := tls.Client(conn, proxyTLSConfigForTest(u.Hostname())) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = tlsConn.Close() + return nil, fmt.Errorf("tls handshake to proxy: %w", err) + } + conn = tlsConn + } + + _ = conn.SetDeadline(time.Now().Add(imapDialTimeout)) + + connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", targetAddr, targetAddr) + if u.User != nil { + username := u.User.Username() + password, _ := u.User.Password() + creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", creds) + } + connectReq += "\r\n" + + if _, err := fmt.Fprint(conn, connectReq); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("send connect: %w", err) + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("read connect response: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + return nil, fmt.Errorf("proxy connect failed: %s", resp.Status) + } + + _ = conn.SetDeadline(time.Time{}) + + if br.Buffered() > 0 { + return &proxyConn{Conn: conn, reader: io.MultiReader(br, conn)}, nil + } + return conn, nil +} + // fetchUnseen searches for UNSEEN messages and processes each one. func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error { criteria := &imap.SearchCriteria{ diff --git a/internal/mail/imap_test.go b/internal/mail/imap_test.go index 22a47ee..7f7d12f 100644 --- a/internal/mail/imap_test.go +++ b/internal/mail/imap_test.go @@ -1,10 +1,20 @@ package mail import ( + "bufio" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" + "fmt" "io" + "math/big" "net" + "net/http" "strings" "testing" "time" @@ -73,13 +83,22 @@ func TestConnectAndWatch_UsesProxyDial_WhenProxySet(t *testing.T) { } } -func TestDialTLSViaSOCKS5_RejectsNonSocks5Scheme(t *testing.T) { - _, err := dialTLSViaSOCKS5("imap.example.com:993", "http://proxy:8080", nil) +func TestDialTLSViaProxy_RejectsUnsupportedScheme(t *testing.T) { + _, err := dialTLSViaProxy("imap.example.com:993", "ftp://proxy:21", nil) if err == nil || !strings.Contains(err.Error(), "unsupported proxy scheme") { t.Fatalf("expected unsupported scheme error, got %v", err) } } +func TestDialTLSViaProxy_RejectsMalformedURL(t *testing.T) { + _, err := dialTLSViaProxy("imap.example.com:993", "://bad", nil) + if err == nil { + t.Fatal("expected error for malformed URL") + } +} + +// --- SOCKS5 path --- + type fakeContextDialer struct{} func (fakeContextDialer) Dial(network, address string) (net.Conn, error) { @@ -93,7 +112,7 @@ func (fakeContextDialer) DialContext(ctx context.Context, network, address strin return nil, errors.New("used dialcontext") } -func TestDialTLSViaSOCKS5_UsesDialContextWithTimeout(t *testing.T) { +func TestDialTLSViaProxy_SOCKS5_UsesDialContextWithTimeout(t *testing.T) { orig := socks5DialerFactory t.Cleanup(func() { socks5DialerFactory = orig }) @@ -104,7 +123,7 @@ func TestDialTLSViaSOCKS5_UsesDialContextWithTimeout(t *testing.T) { return fakeContextDialer{}, nil } - _, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil) + _, err := dialTLSViaProxy("imap.example.com:993", "socks5://127.0.0.1:1080", nil) if err == nil || !strings.Contains(err.Error(), "proxy dial: used dialcontext") { t.Fatalf("expected DialContext path, got %v", err) } @@ -143,7 +162,7 @@ func (d contextConnDialer) DialContext(ctx context.Context, network, address str return d.conn, nil } -func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) { +func TestDialTLSViaProxy_SOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) { orig := socks5DialerFactory t.Cleanup(func() { socks5DialerFactory = orig }) @@ -152,7 +171,7 @@ func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) { return contextConnDialer{conn: conn}, nil } - _, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil) + _, err := dialTLSViaProxy("imap.example.com:993", "socks5://127.0.0.1:1080", nil) if err == nil || !strings.Contains(err.Error(), "tls handshake") { t.Fatalf("expected tls handshake error, got %v", err) } @@ -160,3 +179,191 @@ func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) { t.Fatal("expected TLS handshake deadline to be set") } } + +// --- HTTP CONNECT path --- + +func startFakeProxy(t *testing.T, handler func(host string) bool) (addr string, cleanup func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + br := bufio.NewReader(c) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if handler(req.URL.Host) { + fmt.Fprintf(c, "HTTP/1.1 200 OK\r\n\r\n") + } else { + fmt.Fprintf(c, "HTTP/1.1 403 Forbidden\r\n\r\n") + } + }(conn) + } + }() + return ln.Addr().String(), func() { + ln.Close() + <-done + } +} + +func TestDialTLSViaProxy_HTTPConnect_ReachesTLSHandshake(t *testing.T) { + addr, cleanup := startFakeProxy(t, func(host string) bool { return true }) + defer cleanup() + + _, err := dialTLSViaProxy("imap.example.com:993", fmt.Sprintf("http://%s", addr), nil) + if err == nil || !strings.Contains(err.Error(), "tls handshake") { + t.Fatalf("expected tls handshake error (CONNECT succeeded), got %v", err) + } +} + +func TestDialTLSViaProxy_HTTPConnect_RejectsOnProxyFailure(t *testing.T) { + addr, cleanup := startFakeProxy(t, func(host string) bool { return false }) + defer cleanup() + + _, err := dialTLSViaProxy("imap.example.com:993", fmt.Sprintf("http://%s", addr), nil) + if err == nil || !strings.Contains(err.Error(), "proxy connect failed") { + t.Fatalf("expected proxy connect failed error, got %v", err) + } +} + +func TestDialTLSViaProxy_HTTPConnect_SendsProxyAuth(t *testing.T) { + var gotAuth string + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go func() { + defer ln.Close() + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return + } + gotAuth = req.Header.Get("Proxy-Authorization") + fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\n\r\n") + }() + + _, _ = dialTLSViaProxy("imap.example.com:993", + fmt.Sprintf("http://testuser:testpass@%s", ln.Addr().String()), nil) + + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) + if gotAuth != expected { + t.Fatalf("expected auth %q, got %q", expected, gotAuth) + } +} + +func TestDialTLSViaProxy_HTTPConnect_DialError(t *testing.T) { + origDial := dialToProxy + t.Cleanup(func() { dialToProxy = origDial }) + + dialToProxy = func(ctx context.Context, network, addr string) (net.Conn, error) { + return nil, errors.New("dial refused") + } + + _, err := dialTLSViaProxy("imap.example.com:993", "http://127.0.0.1:1", nil) + if err == nil || !strings.Contains(err.Error(), "dial proxy") { + t.Fatalf("expected dial proxy error, got %v", err) + } +} + +// --- HTTPS CONNECT path --- + +func generateTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + DNSNames: []string{"127.0.0.1"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + Leaf: cert, + } +} + +func TestDialTLSViaProxy_HTTPSConnect_ReachesTLSHandshake(t *testing.T) { + cert := generateTestCert(t) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tlsLn := tls.NewListener(ln, &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := tlsLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + br := bufio.NewReader(c) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if req.URL.Host != "" { + fmt.Fprintf(c, "HTTP/1.1 200 OK\r\n\r\n") + } + }(conn) + } + }() + defer func() { + tlsLn.Close() + <-done + }() + + // Override dialToProxy to return raw TCP, but inject InsecureSkipVerify + // so the TLS handshake to proxy succeeds with the test cert. + origDial := dialToProxy + t.Cleanup(func() { dialToProxy = origDial }) + + origProxyTLS := proxyTLSConfigForTest + t.Cleanup(func() { proxyTLSConfigForTest = origProxyTLS }) + + proxyTLSConfigForTest = func(host string) *tls.Config { + return &tls.Config{ServerName: host, InsecureSkipVerify: true} + } + + _, err = dialTLSViaProxy("imap.example.com:993", + fmt.Sprintf("https://%s", ln.Addr().String()), nil) + if err == nil || !strings.Contains(err.Error(), "tls handshake") { + t.Fatalf("expected tls handshake error (HTTPS CONNECT succeeded), got %v", err) + } +}