返回 DeepSeek-Reasonix
netclient.go
根目录 / internal / netclient / netclient.go
1 // Package netclient builds HTTP clients and proxy resolvers that share Reasonix's
2 // user-facing proxy settings. web_fetch reuses the resolver while keeping its own
3 // dial-time SSRF guard.
4 package netclient
5
6 import (
7 "context"
8 "fmt"
9 "net"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "golang.org/x/net/http/httpproxy"
17
18 "reasonix/internal/sysproxy"
19 )
20
21 const (
22 ModeAuto = "auto"
23 ModeEnv = "env"
24 ModeCustom = "custom"
25 ModeOff = "off"
26 )
27
28 // ProxySpec is the resolved proxy configuration used by network clients. URL is
29 // an advanced override; otherwise Type/Server/Port/Credentials are composed into a
30 // proxy URL. NoProxy is honored for custom proxies. DirectHosts always bypass the
31 // proxy in every mode (the caller derives them, e.g. from no_proxy providers).
32 type ProxySpec struct {
33 Mode string
34 URL string
35 NoProxy string
36 Type string
37 Server string
38 Port int
39 Username string
40 Password string
41 DirectHosts []string
42 }
43
44 // TransportOptions lets callers keep their existing network timeouts while
45 // sharing proxy behavior. ForceIPv4 pins the dialer to tcp4 — the desktop updater
46 // uses it to retry over IPv4 when an IPv6 route (CN → Cloudflare) resets mid-transfer.
47 type TransportOptions struct {
48 DialTimeout time.Duration
49 KeepAlive time.Duration
50 TLSHandshakeTimeout time.Duration
51 ResponseHeaderTimeout time.Duration
52 ForceIPv4 bool
53 }
54
55 // NormalizeMode maps empty and unknown modes to auto, preserving a fail-open
56 // default for older configs.
57 func NormalizeMode(mode string) string {
58 switch strings.ToLower(strings.TrimSpace(mode)) {
59 case ModeEnv:
60 return ModeEnv
61 case ModeCustom:
62 return ModeCustom
63 case ModeOff:
64 return ModeOff
65 default:
66 return ModeAuto
67 }
68 }
69
70 // Validate reports whether spec can be used. Non-custom modes have no required
71 // fields; custom needs either a complete URL or a structured server+port.
72 func Validate(spec ProxySpec) error {
73 _, err := proxyFunc(spec)
74 return err
75 }
76
77 // ProxyFunc returns the per-request proxy resolver for spec.
78 func ProxyFunc(spec ProxySpec) (func(*http.Request) (*url.URL, error), error) {
79 return proxyFunc(spec)
80 }
81
82 // NewHTTPClient returns an HTTP client with Reasonix proxy settings applied.
83 func NewHTTPClient(spec ProxySpec, opts TransportOptions) (*http.Client, error) {
84 tr, err := NewTransport(spec, opts)
85 if err != nil {
86 return nil, err
87 }
88 return &http.Client{Transport: tr}, nil
89 }
90
91 // NewTransport clones net/http's default transport and overlays the requested
92 // proxy and timeout knobs. Cloning preserves defaults such as HTTP/2 support,
93 // connection pooling, and environment-proxy behavior for auto/env modes.
94 func NewTransport(spec ProxySpec, opts TransportOptions) (*http.Transport, error) {
95 tr := defaultTransport()
96 proxy, err := proxyFunc(spec)
97 if err != nil {
98 return nil, err
99 }
100 tr.Proxy = proxy
101 if opts.DialTimeout != 0 || opts.KeepAlive != 0 || opts.ForceIPv4 {
102 d := &net.Dialer{Timeout: opts.DialTimeout, KeepAlive: opts.KeepAlive}
103 if opts.ForceIPv4 {
104 // Default-transport dialer uses 30s/30s; keep those when no explicit
105 // timeout is set so forcing IPv4 doesn't drop the dial deadline.
106 if d.Timeout == 0 {
107 d.Timeout = 30 * time.Second
108 }
109 if d.KeepAlive == 0 {
110 d.KeepAlive = 30 * time.Second
111 }
112 tr.DialContext = func(ctx context.Context, _, addr string) (net.Conn, error) {
113 return d.DialContext(ctx, "tcp4", addr)
114 }
115 } else {
116 tr.DialContext = d.DialContext
117 }
118 }
119 if opts.TLSHandshakeTimeout != 0 {
120 tr.TLSHandshakeTimeout = opts.TLSHandshakeTimeout
121 }
122 if opts.ResponseHeaderTimeout != 0 {
123 tr.ResponseHeaderTimeout = opts.ResponseHeaderTimeout
124 }
125 return tr, nil
126 }
127
128 // Summary returns a redacted, user-facing description for diagnostics.
129 func Summary(spec ProxySpec) string {
130 switch NormalizeMode(spec.Mode) {
131 case ModeOff:
132 return "off (direct)"
133 case ModeEnv:
134 return "env"
135 case ModeCustom:
136 u, err := customProxyURL(spec)
137 if err != nil {
138 return "custom (invalid)"
139 }
140 return "custom (" + redactURL(u) + ")"
141 default:
142 return "auto (env)"
143 }
144 }
145
146 func defaultTransport() *http.Transport {
147 if base, ok := http.DefaultTransport.(*http.Transport); ok {
148 return base.Clone()
149 }
150 return &http.Transport{Proxy: http.ProxyFromEnvironment}
151 }
152
153 func proxyFunc(spec ProxySpec) (func(*http.Request) (*url.URL, error), error) {
154 base, err := baseProxyFunc(spec)
155 if err != nil {
156 return nil, err
157 }
158 return withDirectHosts(base, spec.DirectHosts), nil
159 }
160
161 func baseProxyFunc(spec ProxySpec) (func(*http.Request) (*url.URL, error), error) {
162 switch NormalizeMode(spec.Mode) {
163 case ModeOff:
164 return nil, nil
165 case ModeCustom:
166 u, err := customProxyURL(spec)
167 if err != nil {
168 return nil, err
169 }
170 cfg := &httpproxy.Config{
171 HTTPProxy: u.String(),
172 HTTPSProxy: u.String(),
173 NoProxy: strings.TrimSpace(spec.NoProxy),
174 }
175 pf := cfg.ProxyFunc()
176 return func(req *http.Request) (*url.URL, error) { return pf(req.URL) }, nil
177 case ModeEnv:
178 return environmentProxyFunc(), nil
179 default:
180 return autoProxyFunc(), nil
181 }
182 }
183
184 // withDirectHosts makes the listed hosts (and their subdomains) bypass the proxy
185 // in every mode. The caller decides which hosts are direct — netclient stays
186 // provider-agnostic. A China-only endpoint reached through a foreign-exit proxy
187 // resets the TLS handshake (SSL_ERROR_SYSCALL, #2803), so its provider marks it.
188 func withDirectHosts(pf func(*http.Request) (*url.URL, error), hosts []string) func(*http.Request) (*url.URL, error) {
189 if pf == nil || len(hosts) == 0 {
190 return pf
191 }
192 norm := make([]string, 0, len(hosts))
193 for _, h := range hosts {
194 if h = strings.ToLower(strings.TrimSpace(h)); h != "" {
195 norm = append(norm, h)
196 }
197 }
198 return func(req *http.Request) (*url.URL, error) {
199 host := strings.ToLower(req.URL.Hostname())
200 for _, h := range norm {
201 if host == h || strings.HasSuffix(host, "."+h) {
202 return nil, nil
203 }
204 }
205 return pf(req)
206 }
207 }
208
209 func environmentProxyFunc() func(*http.Request) (*url.URL, error) {
210 cfg := httpproxy.FromEnvironment()
211 pf := cfg.ProxyFunc()
212 return func(req *http.Request) (*url.URL, error) { return pf(req.URL) }
213 }
214
215 // autoProxyFunc honors environment proxy vars first, then falls back to the OS
216 // system proxy (Windows IE/PAC/WPAD) so corporate Windows machines work without
217 // any manual HTTP_PROXY setup. Non-Windows resolves to env-only.
218 func autoProxyFunc() func(*http.Request) (*url.URL, error) {
219 pf := httpproxy.FromEnvironment().ProxyFunc()
220 return func(req *http.Request) (*url.URL, error) {
221 if u, err := pf(req.URL); err != nil || u != nil {
222 return u, err
223 }
224 return sysproxy.ForURL(req.URL)
225 }
226 }
227
228 func customProxyURL(spec ProxySpec) (*url.URL, error) {
229 if raw := strings.TrimSpace(spec.URL); raw != "" {
230 u, err := url.Parse(raw)
231 if err != nil {
232 return nil, fmt.Errorf("network proxy_url: %w", err)
233 }
234 if err := validateProxyURL(u); err != nil {
235 return nil, err
236 }
237 return u, nil
238 }
239 typ := strings.ToLower(strings.TrimSpace(spec.Type))
240 if typ == "" {
241 typ = "http"
242 }
243 switch typ {
244 case "http", "https", "socks5", "socks5h":
245 default:
246 return nil, fmt.Errorf("network proxy type %q: must be http|https|socks5|socks5h", spec.Type)
247 }
248 server := strings.TrimSpace(spec.Server)
249 if server == "" {
250 return nil, fmt.Errorf("network proxy server is required when proxy_mode = custom")
251 }
252 if spec.Port <= 0 || spec.Port > 65535 {
253 return nil, fmt.Errorf("network proxy port must be 1..65535")
254 }
255 u := &url.URL{Scheme: typ, Host: net.JoinHostPort(server, strconv.Itoa(spec.Port))}
256 if spec.Username != "" {
257 if spec.Password != "" {
258 u.User = url.UserPassword(spec.Username, spec.Password)
259 } else {
260 u.User = url.User(spec.Username)
261 }
262 }
263 return u, nil
264 }
265
266 func validateProxyURL(u *url.URL) error {
267 switch strings.ToLower(u.Scheme) {
268 case "http", "https", "socks5", "socks5h":
269 default:
270 return fmt.Errorf("network proxy_url scheme %q: must be http|https|socks5|socks5h", u.Scheme)
271 }
272 if u.Hostname() == "" {
273 return fmt.Errorf("network proxy_url host is required")
274 }
275 return nil
276 }
277
278 func redactURL(u *url.URL) string {
279 cp := *u
280 if cp.User != nil {
281 if name := cp.User.Username(); name != "" {
282 cp.User = url.User(name)
283 } else {
284 cp.User = nil
285 }
286 }
287 return cp.String()
288 }
289
289 lines GO