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 <noreply@anthropic.com>
This commit is contained in:
+106
-23
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user