MiroFish / internal /netutil /proxy_dial.go
pjpjq's picture
feat(space): 部署 notion-manager 并启用密码保护
5d65746 verified
Raw
History Blame Contribute Delete
6.18 kB
// Package netutil offers shared low-level networking helpers used by
// notion-manager. The functions here are deliberately tiny and free of
// any project-specific knowledge so both internal/proxy (Notion runtime
// traffic) and internal/msalogin (Microsoft auth flow) can reuse them
// without dragging cyclic dependencies between those packages.
package netutil
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/proxy"
)
// userAgent header value sent on outgoing CONNECT preludes. Some HTTP
// proxies reject requests with no UA; this string mimics a recent
// Chrome on Windows, matching the rest of the project's outbound
// fingerprint.
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
// DialThroughProxy opens a TCP connection to addr, optionally tunnelling
// through proxyURL.
//
// Supported schemes:
// - "" (no proxy) → direct net.Dialer
// - "socks5", "socks5h" → golang.org/x/net/proxy.SOCKS5
// - "http", "https" → HTTP CONNECT
//
// On unsupported schemes returns an explicit error so callers (e.g. the
// bulk-register UI) can surface "unsupported proxy scheme" instead of
// silently bypassing.
func DialThroughProxy(ctx context.Context, network, addr, proxyURL string) (net.Conn, error) {
base := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
if proxyURL == "" {
return base.DialContext(ctx, network, addr)
}
u, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("parse proxy %q: %w", proxyURL, err)
}
switch strings.ToLower(u.Scheme) {
case "socks5", "socks5h":
var auth *proxy.Auth
if u.User != nil {
pw, _ := u.User.Password()
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
}
d, err := proxy.SOCKS5("tcp", u.Host, auth, base)
if err != nil {
return nil, fmt.Errorf("socks5 dialer: %w", err)
}
// Newer x/net/proxy returns ContextDialer; older Dialer is the fallback.
if cd, ok := d.(proxy.ContextDialer); ok {
return cd.DialContext(ctx, network, addr)
}
// Last-ditch: synchronous dial honoring the context deadline.
return dialWithCtx(ctx, func() (net.Conn, error) { return d.Dial(network, addr) })
case "http", "https":
return httpConnect(ctx, base, u, addr)
default:
return nil, fmt.Errorf("unsupported proxy scheme: %q", u.Scheme)
}
}
// httpConnect implements RFC 7231 §4.3.6 (HTTP CONNECT). It is sufficient
// for both http://proxy and https://proxy URLs; the scheme only affects
// whether we wrap the proxy hop in TLS — for the moment we don't, since
// HTTPS-to-proxy is uncommon for SOCKS-replacement use cases. If a real
// deployment requires it we'd add a TLS layer here.
func httpConnect(ctx context.Context, base *net.Dialer, proxyURL *url.URL, target string) (net.Conn, error) {
conn, err := base.DialContext(ctx, "tcp", proxyURL.Host)
if err != nil {
return nil, fmt.Errorf("dial proxy %s: %w", proxyURL.Host, err)
}
header := make(http.Header)
header.Set("Host", target)
header.Set("User-Agent", userAgent)
if proxyURL.User != nil {
username := proxyURL.User.Username()
password, _ := proxyURL.User.Password()
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
header.Set("Proxy-Authorization", "Basic "+creds)
}
req := &http.Request{
Method: "CONNECT",
URL: &url.URL{Opaque: target},
Host: target,
Header: header,
}
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetWriteDeadline(deadline)
_ = conn.SetReadDeadline(deadline)
}
if err := req.Write(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("write CONNECT: %w", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, req)
if err != nil {
conn.Close()
return nil, fmt.Errorf("read CONNECT response: %w", err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
conn.Close()
return nil, fmt.Errorf("CONNECT %s: %s", target, resp.Status)
}
// Reset deadlines so subsequent TLS / HTTP I/O isn't bounded by the
// CONNECT deadline.
_ = conn.SetReadDeadline(time.Time{})
_ = conn.SetWriteDeadline(time.Time{})
// Drain anything the proxy may have buffered ahead of our reads
// (rare; some proxies write straight after the 200).
if br.Buffered() > 0 {
buf, _ := br.Peek(br.Buffered())
conn = &prefixConn{Conn: conn, prefix: buf}
}
return conn, nil
}
// prefixConn replays bytes the bufio.Reader had already buffered before the
// caller's first Read. Without this we'd lose any TLS handshake bytes the
// proxy flushed alongside its 200 response.
type prefixConn struct {
net.Conn
prefix []byte
}
func (p *prefixConn) Read(b []byte) (int, error) {
if len(p.prefix) > 0 {
n := copy(b, p.prefix)
p.prefix = p.prefix[n:]
return n, nil
}
return p.Conn.Read(b)
}
// dialWithCtx adapts a context-less Dial into a context-aware one by racing
// it against ctx.Done(). The dropped connection is not torn down — the
// caller is expected to time out via the deadline rather than relying on
// cancellation alone.
func dialWithCtx(ctx context.Context, dial func() (net.Conn, error)) (net.Conn, error) {
type result struct {
c net.Conn
err error
}
ch := make(chan result, 1)
go func() {
c, err := dial()
ch <- result{c, err}
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
return r.c, r.err
}
}
// ValidateProxyURL checks that proxyURL has a supported scheme. Empty
// string is treated as "no proxy" and accepted. Used by callers like
// msalogin.New and /admin/settings so misconfigurations surface at
// validation time rather than per-request.
func ValidateProxyURL(proxyURL string) error {
if proxyURL == "" {
return nil
}
u, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("parse proxy %q: %w", proxyURL, err)
}
switch strings.ToLower(u.Scheme) {
case "socks5", "socks5h", "http", "https":
if u.Host == "" {
return fmt.Errorf("proxy URL %q is missing host", proxyURL)
}
return nil
default:
return fmt.Errorf("unsupported proxy scheme %q (want http/https/socks5)", u.Scheme)
}
}